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