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,148 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Extract per-turn token usage from an Anthropic Messages SSE stream.
4
+
5
+ Anthropic streams a turn as Server-Sent Events. Usage is split across two events:
6
+ ``message_start`` carries ``input_tokens`` and the model; the final
7
+ ``message_delta`` carries the cumulative ``output_tokens``. This reads both from
8
+ the raw ``data:`` lines a relay tees off the response, so the relay can record a
9
+ turn without buffering or re-encoding the body.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, cast
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterable
20
+
21
+ _DATA_PREFIX = "data:"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class StreamUsage:
26
+ """The token usage read off one Anthropic turn (session-agnostic).
27
+
28
+ Attributes:
29
+ input_tokens: Fresh prompt tokens from ``message_start``.
30
+ output_tokens: Completion tokens from the final ``message_delta``.
31
+ model: The serving model, or ``None`` if the stream omitted it.
32
+ cache_creation_tokens: Prompt tokens written to the cache this turn.
33
+ cache_read_tokens: Prompt tokens served from the cache this turn.
34
+ turn_id: The provider's id for this turn, or ``None`` if unreported.
35
+ """
36
+
37
+ input_tokens: int
38
+ output_tokens: int
39
+ model: str | None
40
+ cache_creation_tokens: int = 0
41
+ cache_read_tokens: int = 0
42
+ turn_id: str | None = None
43
+
44
+
45
+ def read_usage(text: str) -> StreamUsage | None:
46
+ """Read a turn's usage from a raw Anthropic response body (SSE or JSON).
47
+
48
+ Claude streams (SSE) interactively but may answer a non-interactive request
49
+ with a single JSON envelope; this tries the streaming shape first, then the
50
+ non-streaming one.
51
+
52
+ Args:
53
+ text: The decoded response body.
54
+
55
+ Returns:
56
+ The turn's usage, or ``None`` if none was found.
57
+ """
58
+ streaming = extract_usage(text.splitlines())
59
+ return streaming if streaming is not None else _from_json(text)
60
+
61
+
62
+ def _from_json(text: str) -> StreamUsage | None:
63
+ try:
64
+ decoded: object = json.loads(text)
65
+ except (json.JSONDecodeError, ValueError):
66
+ return None
67
+ if not isinstance(decoded, dict):
68
+ return None
69
+ envelope = cast("dict[str, object]", decoded)
70
+ usage = envelope.get("usage")
71
+ input_tokens = _usage_token(usage, "input_tokens")
72
+ output_tokens = _usage_token(usage, "output_tokens")
73
+ if input_tokens is None and output_tokens is None:
74
+ return None
75
+ return StreamUsage(
76
+ input_tokens or 0,
77
+ output_tokens or 0,
78
+ _as_str(envelope.get("model")),
79
+ _usage_token(usage, "cache_creation_input_tokens") or 0,
80
+ _usage_token(usage, "cache_read_input_tokens") or 0,
81
+ _as_str(envelope.get("id")),
82
+ )
83
+
84
+
85
+ def extract_usage(lines: Iterable[str]) -> StreamUsage | None:
86
+ """Read token usage from the ``data:`` lines of an Anthropic SSE stream.
87
+
88
+ Args:
89
+ lines: The response's lines (``event:``/blank lines are ignored).
90
+
91
+ Returns:
92
+ The turn's usage, or ``None`` if no usage was seen (e.g. a non-stream
93
+ error response).
94
+ """
95
+ input_tokens: int | None = None
96
+ output_tokens: int | None = None
97
+ cache_creation: int | None = None
98
+ cache_read: int | None = None
99
+ model: str | None = None
100
+ turn_id: str | None = None
101
+ for raw in lines:
102
+ event = _data_event(raw)
103
+ if event is None:
104
+ continue
105
+ kind = event.get("type")
106
+ if kind == "message_start":
107
+ message = event.get("message")
108
+ if isinstance(message, dict):
109
+ fields = cast("dict[str, object]", message)
110
+ model = _as_str(fields.get("model"))
111
+ turn_id = _as_str(fields.get("id"))
112
+ usage = fields.get("usage")
113
+ input_tokens = _usage_token(usage, "input_tokens")
114
+ cache_creation = _usage_token(usage, "cache_creation_input_tokens")
115
+ cache_read = _usage_token(usage, "cache_read_input_tokens")
116
+ elif kind == "message_delta":
117
+ seen = _usage_token(event.get("usage"), "output_tokens")
118
+ if seen is not None:
119
+ output_tokens = seen
120
+ if input_tokens is None and output_tokens is None:
121
+ return None
122
+ return StreamUsage(
123
+ input_tokens or 0, output_tokens or 0, model, cache_creation or 0, cache_read or 0, turn_id
124
+ )
125
+
126
+
127
+ def _data_event(raw: str) -> dict[str, object] | None:
128
+ line = raw.strip()
129
+ if not line.startswith(_DATA_PREFIX):
130
+ return None
131
+ try:
132
+ decoded: object = json.loads(line[len(_DATA_PREFIX) :].strip())
133
+ except (json.JSONDecodeError, ValueError):
134
+ return None
135
+ return cast("dict[str, object]", decoded) if isinstance(decoded, dict) else None
136
+
137
+
138
+ def _usage_token(usage: object, key: str) -> int | None:
139
+ if not isinstance(usage, dict):
140
+ return None
141
+ value = cast("dict[str, object]", usage).get(key)
142
+ if isinstance(value, bool) or not isinstance(value, int):
143
+ return None
144
+ return value
145
+
146
+
147
+ def _as_str(value: object) -> str | None:
148
+ return value if isinstance(value, str) and value else None
@@ -0,0 +1,112 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Extract per-turn token usage from an OpenAI Chat Completions response (vibe/Mistral).
4
+
5
+ Mistral's vibe CLI speaks the OpenAI Chat Completions API. Usage arrives in a
6
+ standard ``usage`` block — in the final SSE chunk when streaming (that chunk
7
+ carries ``usage`` with an empty ``choices``), or in the single JSON envelope of a
8
+ non-streaming response. ``prompt_tokens`` counts the whole input including any
9
+ cache reads, so fresh input is ``prompt_tokens - cached_tokens``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import cast
16
+
17
+ from generic_ml_wrapper.adapter.outbound.gateway.anthropic_sse import StreamUsage
18
+
19
+ _DATA_PREFIX = "data:"
20
+
21
+
22
+ def read_usage(text: str) -> StreamUsage | None:
23
+ """Read a turn's usage from an OpenAI Chat Completions response body.
24
+
25
+ Tries the streaming shape (SSE ``data:`` lines) first, then a single
26
+ non-streaming JSON envelope.
27
+
28
+ Args:
29
+ text: The decoded response body.
30
+
31
+ Returns:
32
+ The turn's usage, or ``None`` if none was found.
33
+ """
34
+ streaming = _from_sse(text)
35
+ return streaming if streaming is not None else _from_json(text)
36
+
37
+
38
+ def _from_sse(text: str) -> StreamUsage | None:
39
+ model: str | None = None
40
+ turn_id: str | None = None
41
+ tokens: tuple[int, int, int] | None = None
42
+ for raw in text.splitlines():
43
+ chunk = _data_event(raw)
44
+ if chunk is None:
45
+ continue
46
+ model = _as_str(chunk.get("model")) or model
47
+ turn_id = _as_str(chunk.get("id")) or turn_id
48
+ seen = _tokens(chunk.get("usage"))
49
+ if seen is not None:
50
+ tokens = seen
51
+ if tokens is None:
52
+ return None
53
+ fresh, output, cached = tokens
54
+ return StreamUsage(fresh, output, model, 0, cached, turn_id)
55
+
56
+
57
+ def _from_json(text: str) -> StreamUsage | None:
58
+ try:
59
+ decoded: object = json.loads(text)
60
+ except (json.JSONDecodeError, ValueError):
61
+ return None
62
+ if not isinstance(decoded, dict):
63
+ return None
64
+ envelope = cast("dict[str, object]", decoded)
65
+ tokens = _tokens(envelope.get("usage"))
66
+ if tokens is None:
67
+ return None
68
+ fresh, output, cached = tokens
69
+ return StreamUsage(
70
+ fresh, output, _as_str(envelope.get("model")), 0, cached, _as_str(envelope.get("id"))
71
+ )
72
+
73
+
74
+ def _data_event(raw: str) -> dict[str, object] | None:
75
+ line = raw.strip()
76
+ if not line.startswith(_DATA_PREFIX):
77
+ return None
78
+ payload = line[len(_DATA_PREFIX) :].strip()
79
+ if payload == "[DONE]":
80
+ return None
81
+ try:
82
+ decoded: object = json.loads(payload)
83
+ except (json.JSONDecodeError, ValueError):
84
+ return None
85
+ return cast("dict[str, object]", decoded) if isinstance(decoded, dict) else None
86
+
87
+
88
+ def _tokens(usage: object) -> tuple[int, int, int] | None:
89
+ """Return ``(fresh_input, output, cache_read)`` from a ``usage`` block, or ``None``."""
90
+ if not isinstance(usage, dict):
91
+ return None
92
+ fields = cast("dict[str, object]", usage)
93
+ prompt = _as_count(fields.get("prompt_tokens"))
94
+ completion = _as_count(fields.get("completion_tokens"))
95
+ cached = _as_count(_get(fields.get("prompt_tokens_details"), "cached_tokens"))
96
+ return (max(prompt - cached, 0), completion, cached) # prompt_tokens includes cache reads
97
+
98
+
99
+ def _get(mapping: object, key: str) -> object:
100
+ if not isinstance(mapping, dict):
101
+ return None
102
+ return cast("dict[str, object]", mapping).get(key)
103
+
104
+
105
+ def _as_count(value: object) -> int:
106
+ if isinstance(value, bool) or not isinstance(value, int):
107
+ return 0
108
+ return value
109
+
110
+
111
+ def _as_str(value: object) -> str | None:
112
+ return value if isinstance(value, str) and value else None
@@ -0,0 +1,85 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Extract per-turn token usage from an OpenAI Responses API SSE stream (Codex).
4
+
5
+ Codex streams a turn as Server-Sent Events and reports usage once, in the final
6
+ ``response.completed`` event's ``response.usage``. Unlike Anthropic, its
7
+ ``input_tokens`` is the TOTAL input including cache reads, so fresh input is
8
+ ``input_tokens - cached_tokens``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import cast
15
+
16
+ from generic_ml_wrapper.adapter.outbound.gateway.anthropic_sse import StreamUsage
17
+
18
+ _DATA_PREFIX = "data:"
19
+
20
+
21
+ def read_usage(text: str) -> StreamUsage | None:
22
+ """Read a turn's usage from a Codex Responses API SSE stream.
23
+
24
+ Args:
25
+ text: The decoded response body.
26
+
27
+ Returns:
28
+ The turn's usage, or ``None`` if no ``response.completed`` usage was seen.
29
+ """
30
+ for raw in text.splitlines():
31
+ line = raw.strip()
32
+ if not line.startswith(_DATA_PREFIX):
33
+ continue
34
+ try:
35
+ decoded: object = json.loads(line[len(_DATA_PREFIX) :].strip())
36
+ except (json.JSONDecodeError, ValueError):
37
+ continue
38
+ if not isinstance(decoded, dict):
39
+ continue
40
+ event = cast("dict[str, object]", decoded)
41
+ if event.get("type") != "response.completed":
42
+ continue
43
+ usage = _usage(event.get("response"))
44
+ if usage is not None:
45
+ return usage
46
+ return None
47
+
48
+
49
+ def _usage(response: object) -> StreamUsage | None:
50
+ if not isinstance(response, dict):
51
+ return None
52
+ fields = cast("dict[str, object]", response)
53
+ usage = fields.get("usage")
54
+ if not isinstance(usage, dict):
55
+ return None
56
+ tokens = cast("dict[str, object]", usage)
57
+ total_input = _as_count(tokens.get("input_tokens"))
58
+ output = _as_count(tokens.get("output_tokens"))
59
+ details = tokens.get("input_tokens_details")
60
+ cached = _as_count(_get(details, "cached_tokens"))
61
+ cache_write = _as_count(_get(details, "cache_write_tokens"))
62
+ return StreamUsage(
63
+ input_tokens=max(total_input - cached, 0), # codex input_tokens includes cache reads
64
+ output_tokens=output,
65
+ model=_as_str(fields.get("model")),
66
+ cache_creation_tokens=cache_write,
67
+ cache_read_tokens=cached,
68
+ turn_id=_as_str(fields.get("id")),
69
+ )
70
+
71
+
72
+ def _get(mapping: object, key: str) -> object:
73
+ if not isinstance(mapping, dict):
74
+ return None
75
+ return cast("dict[str, object]", mapping).get(key)
76
+
77
+
78
+ def _as_count(value: object) -> int:
79
+ if isinstance(value, bool) or not isinstance(value, int):
80
+ return 0
81
+ return value
82
+
83
+
84
+ def _as_str(value: object) -> str | None:
85
+ return value if isinstance(value, str) and value else None