echoact 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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""The wire between the application process and the synthesis worker.
|
|
2
|
+
|
|
3
|
+
A.2 puts synthesis in a child process because N-03's enforced limits and
|
|
4
|
+
N-21's separate measurement both need an operating-system resource container,
|
|
5
|
+
and because N-22 gives five seconds to release resources -- which means the
|
|
6
|
+
worker must be killable mid-segment. That last point shapes this protocol:
|
|
7
|
+
|
|
8
|
+
* The parent owns all durable state. The worker holds a model and nothing
|
|
9
|
+
else, so killing it at any instant loses no record and corrupts nothing.
|
|
10
|
+
* Every audio payload is written to a file the parent named *before* asking
|
|
11
|
+
for it, and the worker reports the path only after the bytes are flushed
|
|
12
|
+
and the file is closed. A worker killed mid-write leaves a file the parent
|
|
13
|
+
already knows to discard, rather than a half-message on a pipe.
|
|
14
|
+
* Requests carry a monotonically increasing ``seq``; replies echo it. A late
|
|
15
|
+
reply for a cancelled request is dropped by the parent rather than matched
|
|
16
|
+
to whatever ran next.
|
|
17
|
+
|
|
18
|
+
Framing is one JSON object per line on stdin/stdout, UTF-8, no embedded
|
|
19
|
+
newlines. The worker's stderr is free-form diagnostics; N-20 forbids body
|
|
20
|
+
text there, so the worker logs lengths and identifiers only.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from dataclasses import asdict, dataclass, field
|
|
27
|
+
from enum import StrEnum
|
|
28
|
+
from typing import Any, TextIO
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MsgType(StrEnum):
|
|
32
|
+
# parent -> worker
|
|
33
|
+
LOAD = "load"
|
|
34
|
+
SYNTHESIZE = "synthesize"
|
|
35
|
+
UNLOAD = "unload"
|
|
36
|
+
PING = "ping"
|
|
37
|
+
SHUTDOWN = "shutdown"
|
|
38
|
+
# worker -> parent
|
|
39
|
+
READY = "ready"
|
|
40
|
+
LOADED = "loaded"
|
|
41
|
+
AUDIO = "audio"
|
|
42
|
+
ERROR = "error"
|
|
43
|
+
PONG = "pong"
|
|
44
|
+
STATS = "stats"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class Load:
|
|
49
|
+
"""Prepare a model. ``model_dir`` is resolved by the parent against the
|
|
50
|
+
F-84 manifest; the worker never searches for weights and never downloads.
|
|
51
|
+
|
|
52
|
+
``allowed_providers`` is F-87's allow-list. The worker refuses to run if
|
|
53
|
+
a constructed session reports a provider outside it -- the installed
|
|
54
|
+
runtime offers a remotely executing one, so N-01 depends on this check
|
|
55
|
+
happening in the process that actually holds the session.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
model_id: str
|
|
59
|
+
model_dir: str
|
|
60
|
+
intra_op_threads: int
|
|
61
|
+
inter_op_threads: int
|
|
62
|
+
allowed_providers: list[str]
|
|
63
|
+
seq: int = 0
|
|
64
|
+
type: str = MsgType.LOAD
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class Synthesize:
|
|
69
|
+
"""Render one segment.
|
|
70
|
+
|
|
71
|
+
``text`` is already normalised and already split by F-81; the worker does
|
|
72
|
+
no segmentation of its own. It must also stop the engine from re-chunking
|
|
73
|
+
internally, or the segment's duration stops being exactly known -- A.5
|
|
74
|
+
measured the engine re-chunking Korean at 120 characters.
|
|
75
|
+
|
|
76
|
+
``out_path`` is chosen by the parent so that a killed worker cannot leave
|
|
77
|
+
a file the parent does not know about. Silence is *not* the worker's
|
|
78
|
+
business: F-82 makes inter-segment silence the application's, and A.5
|
|
79
|
+
found the engine's own silence parameter inert once we chunk first.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
job_id: str
|
|
83
|
+
segment_index: int
|
|
84
|
+
text: str
|
|
85
|
+
lang: str
|
|
86
|
+
voice_id: str
|
|
87
|
+
speed: float
|
|
88
|
+
total_steps: int
|
|
89
|
+
out_path: str
|
|
90
|
+
seq: int = 0
|
|
91
|
+
type: str = MsgType.SYNTHESIZE
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(slots=True)
|
|
95
|
+
class Unload:
|
|
96
|
+
"""F-19's explicit release, without ending the worker process."""
|
|
97
|
+
|
|
98
|
+
seq: int = 0
|
|
99
|
+
type: str = MsgType.UNLOAD
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(slots=True)
|
|
103
|
+
class Ping:
|
|
104
|
+
seq: int = 0
|
|
105
|
+
type: str = MsgType.PING
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class Shutdown:
|
|
110
|
+
seq: int = 0
|
|
111
|
+
type: str = MsgType.SHUTDOWN
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(slots=True)
|
|
115
|
+
class Ready:
|
|
116
|
+
"""Sent once at start-up, before any request is read."""
|
|
117
|
+
|
|
118
|
+
pid: int
|
|
119
|
+
seq: int = 0
|
|
120
|
+
type: str = MsgType.READY
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(slots=True)
|
|
124
|
+
class Loaded:
|
|
125
|
+
model_id: str
|
|
126
|
+
sample_rate: int
|
|
127
|
+
voices: list[str]
|
|
128
|
+
providers: list[str]
|
|
129
|
+
load_seconds: float
|
|
130
|
+
seq: int = 0
|
|
131
|
+
type: str = MsgType.LOADED
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(slots=True)
|
|
135
|
+
class Audio:
|
|
136
|
+
"""One segment's audio, already on disk.
|
|
137
|
+
|
|
138
|
+
``frame_count`` is authoritative for the segment's duration: F-82 forbids
|
|
139
|
+
resampling, so frames divided by the model's rate is the exact length, and
|
|
140
|
+
the parent builds the segment time table from these numbers rather than
|
|
141
|
+
from anything the engine reports about seconds.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
job_id: str
|
|
145
|
+
segment_index: int
|
|
146
|
+
out_path: str
|
|
147
|
+
frame_count: int
|
|
148
|
+
sample_rate: int
|
|
149
|
+
synth_seconds: float
|
|
150
|
+
peak: float
|
|
151
|
+
seq: int = 0
|
|
152
|
+
type: str = MsgType.AUDIO
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(slots=True)
|
|
156
|
+
class Error:
|
|
157
|
+
"""A failure attributable to one request, or to the worker as a whole.
|
|
158
|
+
|
|
159
|
+
``code`` is an ``echoact.errors.Code`` value. The worker chooses among a
|
|
160
|
+
small set -- it cannot know the caller's context -- and the parent maps
|
|
161
|
+
the rest.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
code: str
|
|
165
|
+
message: str
|
|
166
|
+
fatal: bool = False
|
|
167
|
+
job_id: str | None = None
|
|
168
|
+
segment_index: int | None = None
|
|
169
|
+
seq: int = 0
|
|
170
|
+
type: str = MsgType.ERROR
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(slots=True)
|
|
174
|
+
class Pong:
|
|
175
|
+
rss_bytes: int
|
|
176
|
+
seq: int = 0
|
|
177
|
+
type: str = MsgType.PONG
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(slots=True)
|
|
181
|
+
class Stats:
|
|
182
|
+
"""Unsolicited resource report, so N-21 can measure the generation job
|
|
183
|
+
apart from total app usage without the parent having to poll on the
|
|
184
|
+
request path."""
|
|
185
|
+
|
|
186
|
+
rss_bytes: int
|
|
187
|
+
cpu_percent: float
|
|
188
|
+
seq: int = 0
|
|
189
|
+
type: str = MsgType.STATS
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_BY_TYPE: dict[str, type] = {
|
|
193
|
+
MsgType.LOAD: Load,
|
|
194
|
+
MsgType.SYNTHESIZE: Synthesize,
|
|
195
|
+
MsgType.UNLOAD: Unload,
|
|
196
|
+
MsgType.PING: Ping,
|
|
197
|
+
MsgType.SHUTDOWN: Shutdown,
|
|
198
|
+
MsgType.READY: Ready,
|
|
199
|
+
MsgType.LOADED: Loaded,
|
|
200
|
+
MsgType.AUDIO: Audio,
|
|
201
|
+
MsgType.ERROR: Error,
|
|
202
|
+
MsgType.PONG: Pong,
|
|
203
|
+
MsgType.STATS: Stats,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
Message = (
|
|
207
|
+
Load | Synthesize | Unload | Ping | Shutdown | Ready | Loaded | Audio | Error | Pong | Stats
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def encode(msg: Any) -> str:
|
|
212
|
+
"""One line of UTF-8 JSON, newline included."""
|
|
213
|
+
return json.dumps(asdict(msg), ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def decode(line: str) -> Message:
|
|
217
|
+
"""Parse one line. A line that is not a known message is a protocol
|
|
218
|
+
error, never something to guess at."""
|
|
219
|
+
data = json.loads(line)
|
|
220
|
+
kind = data.get("type")
|
|
221
|
+
cls = _BY_TYPE.get(kind)
|
|
222
|
+
if cls is None:
|
|
223
|
+
raise ValueError(f"unknown worker message type: {kind!r}")
|
|
224
|
+
return cls(**data)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def write_message(stream: TextIO, msg: Any) -> None:
|
|
228
|
+
stream.write(encode(msg))
|
|
229
|
+
stream.flush()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(slots=True)
|
|
233
|
+
class Counter:
|
|
234
|
+
"""Sequence numbers, so a reply to a cancelled request can be recognised
|
|
235
|
+
and dropped rather than mistaken for the current one."""
|
|
236
|
+
|
|
237
|
+
value: int = field(default=0)
|
|
238
|
+
|
|
239
|
+
def next(self) -> int:
|
|
240
|
+
self.value += 1
|
|
241
|
+
return self.value
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Pinning the inference runtime to local CPU execution (F-87, N-01, N-05).
|
|
2
|
+
|
|
3
|
+
The installed ONNX Runtime on the reference machine reports
|
|
4
|
+
|
|
5
|
+
['AzureExecutionProvider', 'CPUExecutionProvider']
|
|
6
|
+
|
|
7
|
+
and selects a provider by the order of the list it is given. N-01's promise
|
|
8
|
+
that no text is sent to an external speech service therefore does not follow
|
|
9
|
+
from "we never asked for Azure": it follows only from an explicit allow-list
|
|
10
|
+
plus a check of what the constructed session actually runs on. Both halves
|
|
11
|
+
live here.
|
|
12
|
+
|
|
13
|
+
Two decisions are worth stating, because the obvious alternatives are wrong:
|
|
14
|
+
|
|
15
|
+
* **Allow-list, not deny-list.** Naming the providers we refuse would leave
|
|
16
|
+
every provider a future runtime build adds silently permitted. The names
|
|
17
|
+
in ``_REMOTE_MARKERS`` and ``_ACCELERATOR_MARKERS`` below classify a
|
|
18
|
+
refusal for the diagnostic message only; refusal itself is decided by
|
|
19
|
+
``ALLOWED_PROVIDERS``, so an unrecognised provider is refused without any
|
|
20
|
+
list needing to know it exists.
|
|
21
|
+
* **Check after construction, not before.** A session is free to fall back
|
|
22
|
+
to, or add, a provider that was not requested; the requested list is an
|
|
23
|
+
intention and ``get_providers()`` is the fact. ``assert_local_only`` reads
|
|
24
|
+
the fact, in the process that holds the session.
|
|
25
|
+
* **Check after, but *pin* before.** The check is not the whole mechanism.
|
|
26
|
+
F-87 says the runtime is restricted by an explicit allow-list "never by
|
|
27
|
+
relying on a default", so ``requested_providers`` exists to be handed to
|
|
28
|
+
session construction; a build that constructed sessions from someone
|
|
29
|
+
else's default and then failed the check would be refusing work it could
|
|
30
|
+
have done locally. The caller that owns the sessions
|
|
31
|
+
(``engine.worker``) is responsible for applying the list it verifies.
|
|
32
|
+
|
|
33
|
+
The allow-list in force can be *narrowed* by a caller -- ``Load`` carries
|
|
34
|
+
one over the wire -- but never widened: ``resolve_allow_list`` intersects a
|
|
35
|
+
request with ``ALLOWED_PROVIDERS`` and refuses anything outside it, and the
|
|
36
|
+
result is what both the pinning and the verification then use.
|
|
37
|
+
|
|
38
|
+
Thread counts also come from here rather than from the runtime's own
|
|
39
|
+
defaults: F-87 requires them to be derived from F-20's CPU budget, and A.5
|
|
40
|
+
measured twenty threads running about twice as slow as two, so the cap is
|
|
41
|
+
not only a limit but the faster setting.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from typing import Any, Final
|
|
49
|
+
|
|
50
|
+
import onnxruntime as ort
|
|
51
|
+
|
|
52
|
+
from ..domain import Budget
|
|
53
|
+
from ..errors import Code, EchoActError
|
|
54
|
+
|
|
55
|
+
#: F-87's allow-list. Exactly one entry: local CPU execution.
|
|
56
|
+
ALLOWED_PROVIDERS: Final[tuple[str, ...]] = ("CPUExecutionProvider",)
|
|
57
|
+
|
|
58
|
+
# Substrings used only to say *why* a provider was refused. Nothing here
|
|
59
|
+
# grants or denies anything.
|
|
60
|
+
_REMOTE_MARKERS: Final[tuple[str, ...]] = ("azure", "cloud", "remote")
|
|
61
|
+
_ACCELERATOR_MARKERS: Final[tuple[str, ...]] = (
|
|
62
|
+
"cuda",
|
|
63
|
+
"tensorrt",
|
|
64
|
+
"rocm",
|
|
65
|
+
"migraphx",
|
|
66
|
+
"dml",
|
|
67
|
+
"directml",
|
|
68
|
+
"coreml",
|
|
69
|
+
"openvino",
|
|
70
|
+
"nnapi",
|
|
71
|
+
"qnn",
|
|
72
|
+
"vitis",
|
|
73
|
+
"cann",
|
|
74
|
+
"webgpu",
|
|
75
|
+
"xnnpack",
|
|
76
|
+
"armnn",
|
|
77
|
+
"acl",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class RuntimeReport:
|
|
83
|
+
"""What F-53 advertises and F-72 exports about execution providers.
|
|
84
|
+
|
|
85
|
+
``refused`` is the interesting field: it records that the installed
|
|
86
|
+
runtime offered something we declined, which is the evidence A-23 asks
|
|
87
|
+
for. An empty ``refused`` list on another machine is equally correct.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
onnxruntime_version: str
|
|
91
|
+
allowed: tuple[str, ...]
|
|
92
|
+
available: tuple[str, ...]
|
|
93
|
+
refused: tuple[str, ...]
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"onnxruntime_version": self.onnxruntime_version,
|
|
98
|
+
"allowed_providers": list(self.allowed),
|
|
99
|
+
"available_providers": list(self.available),
|
|
100
|
+
"refused_providers": list(self.refused),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def available_providers() -> list[str]:
|
|
105
|
+
"""Everything the installed runtime offers, allowed or not."""
|
|
106
|
+
return list(ort.get_available_providers())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def refused_providers() -> list[str]:
|
|
110
|
+
"""Offered but outside the allow-list. Reported, never used."""
|
|
111
|
+
return [p for p in available_providers() if p not in ALLOWED_PROVIDERS]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve_allow_list(requested: Iterable[str] | None) -> tuple[str, ...]:
|
|
115
|
+
"""The allow-list in force, given what a caller asked for.
|
|
116
|
+
|
|
117
|
+
``Load.allowed_providers`` crosses a process boundary, so it is a
|
|
118
|
+
request and not an authority: it can narrow the product's allow-list --
|
|
119
|
+
a parent that knows it wants only one provider says so and gets exactly
|
|
120
|
+
that -- and can never widen it. A request naming nothing we allow is a
|
|
121
|
+
refusal rather than a silent fall-back to the full list, because a
|
|
122
|
+
caller that asked for no usable provider must not be given one.
|
|
123
|
+
"""
|
|
124
|
+
if requested is None:
|
|
125
|
+
return ALLOWED_PROVIDERS
|
|
126
|
+
asked = [str(p) for p in requested]
|
|
127
|
+
overreach = [p for p in asked if p not in ALLOWED_PROVIDERS]
|
|
128
|
+
if overreach:
|
|
129
|
+
kinds = ", ".join(f"{p} ({provider_kind(p)})" for p in overreach)
|
|
130
|
+
raise EchoActError(
|
|
131
|
+
Code.RUNTIME_PROVIDER_REFUSED,
|
|
132
|
+
f"The requested execution providers exceed the local-only allow-list: {kinds}",
|
|
133
|
+
detail={"requested": asked, "refused": overreach, "allowed": list(ALLOWED_PROVIDERS)},
|
|
134
|
+
)
|
|
135
|
+
narrowed = tuple(p for p in ALLOWED_PROVIDERS if p in asked)
|
|
136
|
+
if not narrowed:
|
|
137
|
+
raise EchoActError(
|
|
138
|
+
Code.RUNTIME_PROVIDER_REFUSED,
|
|
139
|
+
"The request names no execution provider to run on.",
|
|
140
|
+
detail={"requested": asked, "allowed": list(ALLOWED_PROVIDERS)},
|
|
141
|
+
)
|
|
142
|
+
return narrowed
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def requested_providers(allowed: Sequence[str] | None = None) -> list[str]:
|
|
146
|
+
"""The provider list to hand to ``InferenceSession``.
|
|
147
|
+
|
|
148
|
+
This is the half of F-87 that has to reach session construction: the
|
|
149
|
+
caller passes this list to whatever builds the sessions, so the
|
|
150
|
+
providers registered on them are ours by decision rather than the
|
|
151
|
+
engine's by default. ``assert_local_only`` then checks the result.
|
|
152
|
+
|
|
153
|
+
Allow-list order is preserved, because the runtime picks by order and an
|
|
154
|
+
allowed provider must never sit behind one we would refuse.
|
|
155
|
+
"""
|
|
156
|
+
allow = ALLOWED_PROVIDERS if allowed is None else tuple(allowed)
|
|
157
|
+
offered = set(available_providers())
|
|
158
|
+
usable = [p for p in allow if p in offered]
|
|
159
|
+
if not usable:
|
|
160
|
+
raise EchoActError(
|
|
161
|
+
Code.RUNTIME_PROVIDER_REFUSED,
|
|
162
|
+
"The inference runtime offers no local CPU execution provider.",
|
|
163
|
+
detail={"allowed": list(allow), "available": available_providers()},
|
|
164
|
+
)
|
|
165
|
+
return usable
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def provider_kind(name: str) -> str:
|
|
169
|
+
"""A word for a refusal message: ``local``, ``remote``, ``accelerator``,
|
|
170
|
+
or ``unrecognised``. Classification never decides the refusal."""
|
|
171
|
+
if name in ALLOWED_PROVIDERS:
|
|
172
|
+
return "local"
|
|
173
|
+
lowered = name.lower()
|
|
174
|
+
if any(m in lowered for m in _REMOTE_MARKERS):
|
|
175
|
+
return "remote"
|
|
176
|
+
if any(m in lowered for m in _ACCELERATOR_MARKERS):
|
|
177
|
+
return "accelerator"
|
|
178
|
+
return "unrecognised"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def normalize_threads(intra_op_threads: int, inter_op_threads: int) -> tuple[int, int]:
|
|
182
|
+
"""Clamp a pair of thread counts to at least one each.
|
|
183
|
+
|
|
184
|
+
Zero is ONNX Runtime's "decide for yourself", which is exactly what F-87
|
|
185
|
+
forbids, so a nonsensical budget becomes one thread rather than an
|
|
186
|
+
uncapped one.
|
|
187
|
+
"""
|
|
188
|
+
return max(1, int(intra_op_threads)), max(1, int(inter_op_threads))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def session_options_for_threads(intra_op_threads: int, inter_op_threads: int) -> ort.SessionOptions:
|
|
192
|
+
"""Session options with the thread counts pinned.
|
|
193
|
+
|
|
194
|
+
Sequential execution matches the pipeline's shape -- its four models run
|
|
195
|
+
one after another -- so inter-op parallelism would buy nothing while
|
|
196
|
+
making the CPU share in F-20 harder to hold.
|
|
197
|
+
"""
|
|
198
|
+
intra, inter = normalize_threads(intra_op_threads, inter_op_threads)
|
|
199
|
+
opts = ort.SessionOptions()
|
|
200
|
+
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
|
201
|
+
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
|
202
|
+
opts.intra_op_num_threads = intra
|
|
203
|
+
opts.inter_op_num_threads = inter
|
|
204
|
+
return opts
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def session_options(budget: Budget) -> ort.SessionOptions:
|
|
208
|
+
"""F-87's thread cap, taken from the budget actually in force (F-78)."""
|
|
209
|
+
return session_options_for_threads(budget.intra_op_threads, budget.inter_op_threads)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def assert_local_only(
|
|
213
|
+
session: Any,
|
|
214
|
+
*,
|
|
215
|
+
component: str = "session",
|
|
216
|
+
allowed: Sequence[str] | None = None,
|
|
217
|
+
) -> list[str]:
|
|
218
|
+
"""Refuse a session that runs anywhere but the allow-list.
|
|
219
|
+
|
|
220
|
+
Reads ``get_providers()`` on the constructed session, which is what the
|
|
221
|
+
runtime will actually execute on; the list passed to the constructor is
|
|
222
|
+
only what was asked for. A session that cannot say -- no method, or an
|
|
223
|
+
empty list -- is refused too, because an unverifiable session is exactly
|
|
224
|
+
the case N-01 cannot afford to wave through.
|
|
225
|
+
|
|
226
|
+
``allowed`` narrows the check to the list actually in force for this
|
|
227
|
+
load, so a caller that asked for less than the product allows is held to
|
|
228
|
+
what it asked for. It can only ever be a subset of
|
|
229
|
+
``ALLOWED_PROVIDERS``; ``resolve_allow_list`` is what produces it.
|
|
230
|
+
"""
|
|
231
|
+
allow = ALLOWED_PROVIDERS if allowed is None else tuple(allowed)
|
|
232
|
+
getter = getattr(session, "get_providers", None)
|
|
233
|
+
if not callable(getter):
|
|
234
|
+
raise EchoActError(
|
|
235
|
+
Code.RUNTIME_PROVIDER_REFUSED,
|
|
236
|
+
f"The {component} cannot report its execution providers.",
|
|
237
|
+
detail={"component": component},
|
|
238
|
+
)
|
|
239
|
+
providers = [str(p) for p in getter()]
|
|
240
|
+
if not providers:
|
|
241
|
+
raise EchoActError(
|
|
242
|
+
Code.RUNTIME_PROVIDER_REFUSED,
|
|
243
|
+
f"The {component} reports no execution provider.",
|
|
244
|
+
detail={"component": component},
|
|
245
|
+
)
|
|
246
|
+
refused = [p for p in providers if p not in allow]
|
|
247
|
+
if refused:
|
|
248
|
+
kinds = ", ".join(f"{p} ({provider_kind(p)})" for p in refused)
|
|
249
|
+
raise EchoActError(
|
|
250
|
+
Code.RUNTIME_PROVIDER_REFUSED,
|
|
251
|
+
f"The {component} runs on a provider outside the local-only allow-list: {kinds}.",
|
|
252
|
+
detail={
|
|
253
|
+
"component": component,
|
|
254
|
+
"refused": refused,
|
|
255
|
+
"allowed": list(allow),
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
return providers
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def verify_sessions(
|
|
262
|
+
sessions: Mapping[str, Any],
|
|
263
|
+
*,
|
|
264
|
+
allowed: Sequence[str] | None = None,
|
|
265
|
+
) -> list[str]:
|
|
266
|
+
"""Check every session a loaded pipeline holds and report what is in use.
|
|
267
|
+
|
|
268
|
+
The return value is what F-53 publishes and F-72 exports: the providers
|
|
269
|
+
actually running, in first-seen order. Verifying nothing is refused --
|
|
270
|
+
a pipeline whose sessions we failed to find would otherwise pass this
|
|
271
|
+
check by having no sessions to fail it.
|
|
272
|
+
"""
|
|
273
|
+
if not sessions:
|
|
274
|
+
raise EchoActError(
|
|
275
|
+
Code.RUNTIME_PROVIDER_REFUSED,
|
|
276
|
+
"No inference session was available to verify.",
|
|
277
|
+
)
|
|
278
|
+
in_use: list[str] = []
|
|
279
|
+
for name, session in sessions.items():
|
|
280
|
+
for provider in assert_local_only(session, component=name, allowed=allowed):
|
|
281
|
+
if provider not in in_use:
|
|
282
|
+
in_use.append(provider)
|
|
283
|
+
return in_use
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def providers_in_use(
|
|
287
|
+
sessions: Iterable[Any],
|
|
288
|
+
*,
|
|
289
|
+
allowed: Sequence[str] | None = None,
|
|
290
|
+
) -> list[str]:
|
|
291
|
+
"""``verify_sessions`` for a bare sequence, when the caller has no names."""
|
|
292
|
+
return verify_sessions(
|
|
293
|
+
{f"session[{i}]": s for i, s in enumerate(sessions)},
|
|
294
|
+
allowed=allowed,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def runtime_report() -> RuntimeReport:
|
|
299
|
+
"""The F-72 diagnostic record for this process's runtime."""
|
|
300
|
+
available = tuple(available_providers())
|
|
301
|
+
return RuntimeReport(
|
|
302
|
+
onnxruntime_version=str(ort.__version__),
|
|
303
|
+
allowed=ALLOWED_PROVIDERS,
|
|
304
|
+
available=available,
|
|
305
|
+
refused=tuple(p for p in available if p not in ALLOWED_PROVIDERS),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
__all__ = [
|
|
310
|
+
"ALLOWED_PROVIDERS",
|
|
311
|
+
"RuntimeReport",
|
|
312
|
+
"assert_local_only",
|
|
313
|
+
"available_providers",
|
|
314
|
+
"normalize_threads",
|
|
315
|
+
"provider_kind",
|
|
316
|
+
"providers_in_use",
|
|
317
|
+
"refused_providers",
|
|
318
|
+
"requested_providers",
|
|
319
|
+
"resolve_allow_list",
|
|
320
|
+
"runtime_report",
|
|
321
|
+
"session_options",
|
|
322
|
+
"session_options_for_threads",
|
|
323
|
+
"verify_sessions",
|
|
324
|
+
]
|