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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. 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
+ ]