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
|
+
"""F-57's error envelope, and the one place an exception becomes a response.
|
|
2
|
+
|
|
3
|
+
Every rejection this service produces goes through :func:`error_response`.
|
|
4
|
+
That matters for three separate promises:
|
|
5
|
+
|
|
6
|
+
* F-57 fixes the shape -- a stable code, a user message, whether a retry can
|
|
7
|
+
succeed, and a request identifier -- and ``EchoActError.to_payload`` already
|
|
8
|
+
produces exactly that, so no route builds a body of its own.
|
|
9
|
+
* Section 2.10 fixes the status for each situation and ``echoact.errors``
|
|
10
|
+
already maps every ``Code`` to one. Statuses are therefore never written at
|
|
11
|
+
a call site; a route raises the code that describes what happened and the
|
|
12
|
+
status follows.
|
|
13
|
+
* N-23 requires the three retryable rejections -- busy, rate limited,
|
|
14
|
+
insufficient resources -- to carry a retry-after hint, and rule 8 forbids a
|
|
15
|
+
permanent one from carrying it. ``EchoActError`` drops a hint attached to a
|
|
16
|
+
permanent code, so the header written here can simply follow the payload.
|
|
17
|
+
|
|
18
|
+
Nothing here ever renders a path, a token, or body text. ``EchoActError``'s
|
|
19
|
+
``detail`` is the only free-form field and every raise site in this package
|
|
20
|
+
puts identifiers and numbers in it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from fastapi import FastAPI
|
|
29
|
+
from fastapi.exceptions import RequestValidationError
|
|
30
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
31
|
+
from starlette.requests import Request
|
|
32
|
+
from starlette.responses import JSONResponse, Response
|
|
33
|
+
|
|
34
|
+
from ..errors import Code, EchoActError
|
|
35
|
+
from ..util import ids
|
|
36
|
+
from ..util.logging import get_logger
|
|
37
|
+
|
|
38
|
+
log = get_logger("service.errors")
|
|
39
|
+
|
|
40
|
+
#: Where the request identifier F-57 requires is echoed. It is a response
|
|
41
|
+
#: header as well as a body field so that a caller can correlate a streamed
|
|
42
|
+
#: audio response, which has no JSON body to put it in.
|
|
43
|
+
REQUEST_ID_HEADER = "X-Request-Id"
|
|
44
|
+
|
|
45
|
+
#: 4.2's leaf names inside a voice-settings object, each mapped to the code
|
|
46
|
+
#: Section 2.10 wants for it. Used only when a body fails schema validation
|
|
47
|
+
#: before any of this package's own checks could name the problem.
|
|
48
|
+
_VOICE_FIELD_CODES: dict[str, Code] = {
|
|
49
|
+
"model_id": Code.MODEL_UNKNOWN,
|
|
50
|
+
"voice_id": Code.VOICE_UNKNOWN,
|
|
51
|
+
"language": Code.LANGUAGE_UNKNOWN,
|
|
52
|
+
"gender": Code.VOICE_UNKNOWN,
|
|
53
|
+
"style": Code.STYLE_UNKNOWN,
|
|
54
|
+
"tempo": Code.TEMPO_OUT_OF_RANGE,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def request_id_of(request: Request) -> str:
|
|
59
|
+
"""The identifier minted for this request, or a fresh one.
|
|
60
|
+
|
|
61
|
+
A fallback is minted rather than omitted: F-57 says every error carries
|
|
62
|
+
one, and an error raised before the gate stored it is exactly the kind a
|
|
63
|
+
user reports.
|
|
64
|
+
"""
|
|
65
|
+
existing = request.scope.get("state", {}).get("request_id")
|
|
66
|
+
return existing if isinstance(existing, str) and existing else ids.request_id()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def error_response(exc: EchoActError, request_id: str) -> JSONResponse:
|
|
70
|
+
"""The wire form of a refusal.
|
|
71
|
+
|
|
72
|
+
``Retry-After`` is written only when the payload carries a hint, and HTTP
|
|
73
|
+
fixes it as whole seconds, so a sub-second hint rounds *up*: rounding down
|
|
74
|
+
would invite a retry that is still too early, which is the one thing the
|
|
75
|
+
header exists to prevent.
|
|
76
|
+
"""
|
|
77
|
+
payload = exc.to_payload(request_id)
|
|
78
|
+
headers = {REQUEST_ID_HEADER: request_id}
|
|
79
|
+
if exc.retry_after_s is not None:
|
|
80
|
+
headers["Retry-After"] = str(max(1, math.ceil(exc.retry_after_s)))
|
|
81
|
+
return JSONResponse(payload, status_code=exc.http_status, headers=headers)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validation_error(exc: RequestValidationError, path: str) -> EchoActError:
|
|
85
|
+
"""Turn a schema failure into a coded refusal.
|
|
86
|
+
|
|
87
|
+
Section 2.10 gives malformed requests 400 and invalid voice settings 422,
|
|
88
|
+
and insists a detailed code accompanies each. ``echoact.errors`` has no
|
|
89
|
+
generic "the body could not be parsed" code, so the code is derived from
|
|
90
|
+
*which* field failed, and the last resort is chosen per endpoint from what
|
|
91
|
+
a body arriving there must state at minimum: a job request that fails to
|
|
92
|
+
parse has, among other things, stated no job kind, and an estimate request
|
|
93
|
+
that fails to parse has supplied no text. Both are true statements about
|
|
94
|
+
the body rather than a stand-in for one.
|
|
95
|
+
"""
|
|
96
|
+
errors = exc.errors()
|
|
97
|
+
field_names = [str(part) for err in errors for part in err.get("loc", ())]
|
|
98
|
+
unknown = sorted(
|
|
99
|
+
{
|
|
100
|
+
str(err["loc"][-1])
|
|
101
|
+
for err in errors
|
|
102
|
+
if err.get("type") == "extra_forbidden" and err.get("loc")
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
if unknown:
|
|
106
|
+
return _unknown_field_error(unknown, field_names)
|
|
107
|
+
if "voice" in field_names:
|
|
108
|
+
for name in field_names:
|
|
109
|
+
if name in _VOICE_FIELD_CODES:
|
|
110
|
+
return EchoActError(_VOICE_FIELD_CODES[name], detail={"field": name})
|
|
111
|
+
return EchoActError(Code.VOICE_SETTINGS_INVALID, detail={"field": "voice"})
|
|
112
|
+
if "kind" in field_names:
|
|
113
|
+
return EchoActError(Code.JOB_KIND_MISSING, detail={"field": "kind"})
|
|
114
|
+
if "idempotency_key" in field_names:
|
|
115
|
+
return EchoActError(Code.IDEMPOTENCY_KEY_MISSING, detail={"field": "idempotency_key"})
|
|
116
|
+
if "text" in field_names:
|
|
117
|
+
return EchoActError(Code.INPUT_EMPTY, detail={"field": "text"})
|
|
118
|
+
if path.endswith("/estimate"):
|
|
119
|
+
return EchoActError(Code.INPUT_EMPTY, detail={"field": "text"})
|
|
120
|
+
return EchoActError(Code.MALFORMED_REQUEST, detail={"fields": field_names})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _unknown_field_error(unknown: list[str], field_names: list[str]) -> EchoActError:
|
|
124
|
+
"""F-54: an option this version does not define is refused, by name.
|
|
125
|
+
|
|
126
|
+
The catalogue has no code for "unrecognised field", so the code is the
|
|
127
|
+
same last resort the rest of the translation uses and carries the status
|
|
128
|
+
Section 2.10 fixes for where the field sat -- 422 inside the voice
|
|
129
|
+
settings, 400 for the body itself -- while ``detail`` names every field
|
|
130
|
+
that was not understood. Naming them is the point: a client that is not
|
|
131
|
+
a person cannot compare its request against a document, and "something in
|
|
132
|
+
your body is wrong" costs it the round trip without telling it what to
|
|
133
|
+
change.
|
|
134
|
+
"""
|
|
135
|
+
listed = ", ".join(unknown)
|
|
136
|
+
if "voice" in field_names:
|
|
137
|
+
# 422 rather than 400 because Section 2.10 fixes that status for
|
|
138
|
+
# the voice settings. The status says where the fault is; the
|
|
139
|
+
# code says what it is, and they are allowed to differ.
|
|
140
|
+
return EchoActError(
|
|
141
|
+
Code.VOICE_SETTINGS_INVALID,
|
|
142
|
+
f"The voice settings name options this version does not define: {listed}.",
|
|
143
|
+
detail={"field": "voice", "unknown_fields": unknown},
|
|
144
|
+
)
|
|
145
|
+
return EchoActError(
|
|
146
|
+
Code.UNKNOWN_OPTION,
|
|
147
|
+
f"The request names fields this version does not define: {listed}.",
|
|
148
|
+
detail={"unknown_fields": unknown},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def http_exception_error(exc: StarletteHTTPException) -> EchoActError:
|
|
153
|
+
"""A routing-layer refusal, given a code.
|
|
154
|
+
|
|
155
|
+
An unknown path and an unsupported method both answer NOT_FOUND. 405
|
|
156
|
+
would tell a caller that the path exists and only the verb is wrong, and
|
|
157
|
+
on a service whose whole contract is twelve fixed operations that is a
|
|
158
|
+
map of the surface for no benefit to a legitimate client.
|
|
159
|
+
"""
|
|
160
|
+
if exc.status_code in (404, 405):
|
|
161
|
+
return EchoActError(Code.NOT_FOUND, "No such endpoint.")
|
|
162
|
+
if exc.status_code == 413:
|
|
163
|
+
return EchoActError(Code.PAYLOAD_TOO_LARGE)
|
|
164
|
+
if exc.status_code == 401:
|
|
165
|
+
return EchoActError(Code.UNAUTHENTICATED)
|
|
166
|
+
if exc.status_code == 403:
|
|
167
|
+
return EchoActError(Code.FORBIDDEN)
|
|
168
|
+
if exc.status_code == 400:
|
|
169
|
+
# Starlette raises a bare 400 for a body its parsers refuse.
|
|
170
|
+
# Falling through to INTERNAL reported a server fault for a
|
|
171
|
+
# client's mistake, and 500 is the one status a client must not
|
|
172
|
+
# be told to stop retrying on.
|
|
173
|
+
return EchoActError(Code.MALFORMED_REQUEST)
|
|
174
|
+
return EchoActError(Code.INTERNAL)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def install_error_handlers(app: FastAPI) -> None:
|
|
178
|
+
"""Route every exception class to the one envelope.
|
|
179
|
+
|
|
180
|
+
The unexpected-exception handler answers INTERNAL and logs the type only.
|
|
181
|
+
An exception's text can quote a file path or a fragment of the caller's
|
|
182
|
+
own body, and F-57 keeps internal paths out of a response while N-20 keeps
|
|
183
|
+
body text out of a log, so neither the client nor the log file gets it.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
async def on_echoact_error(request: Request, exc: Exception) -> Response:
|
|
187
|
+
assert isinstance(exc, EchoActError)
|
|
188
|
+
request_id = request_id_of(request)
|
|
189
|
+
_log_refusal(request, exc, request_id)
|
|
190
|
+
return error_response(exc, request_id)
|
|
191
|
+
|
|
192
|
+
async def on_validation_error(request: Request, exc: Exception) -> Response:
|
|
193
|
+
assert isinstance(exc, RequestValidationError)
|
|
194
|
+
translated = validation_error(exc, request.scope.get("path", ""))
|
|
195
|
+
request_id = request_id_of(request)
|
|
196
|
+
_log_refusal(request, translated, request_id)
|
|
197
|
+
return error_response(translated, request_id)
|
|
198
|
+
|
|
199
|
+
async def on_http_exception(request: Request, exc: Exception) -> Response:
|
|
200
|
+
assert isinstance(exc, StarletteHTTPException)
|
|
201
|
+
translated = http_exception_error(exc)
|
|
202
|
+
request_id = request_id_of(request)
|
|
203
|
+
_log_refusal(request, translated, request_id)
|
|
204
|
+
return error_response(translated, request_id)
|
|
205
|
+
|
|
206
|
+
async def on_unexpected(request: Request, exc: Exception) -> Response:
|
|
207
|
+
request_id = request_id_of(request)
|
|
208
|
+
log.exception(
|
|
209
|
+
"unhandled %s in %s (request=%s)",
|
|
210
|
+
type(exc).__name__,
|
|
211
|
+
request.scope.get("path", ""),
|
|
212
|
+
request_id,
|
|
213
|
+
)
|
|
214
|
+
return error_response(EchoActError(Code.INTERNAL), request_id)
|
|
215
|
+
|
|
216
|
+
app.add_exception_handler(EchoActError, on_echoact_error)
|
|
217
|
+
app.add_exception_handler(RequestValidationError, on_validation_error)
|
|
218
|
+
app.add_exception_handler(StarletteHTTPException, on_http_exception)
|
|
219
|
+
app.add_exception_handler(Exception, on_unexpected)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _log_refusal(request: Request, exc: EchoActError, request_id: str) -> None:
|
|
223
|
+
"""N-25's traceable line for a refusal: identifiers and a code, nothing else.
|
|
224
|
+
|
|
225
|
+
The path is logged without its query string. Nothing in this contract
|
|
226
|
+
puts a token in a URL (N-17), but a log line that would print one if it
|
|
227
|
+
ever appeared is a defect waiting for a future endpoint.
|
|
228
|
+
"""
|
|
229
|
+
log.info(
|
|
230
|
+
"%s %s -> %d %s (request=%s)",
|
|
231
|
+
request.scope.get("method", "?"),
|
|
232
|
+
request.scope.get("path", "?"),
|
|
233
|
+
exc.http_status,
|
|
234
|
+
exc.code.value,
|
|
235
|
+
request_id,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def error_document(code: Code) -> dict[str, Any]:
|
|
240
|
+
"""One example error body, for the OpenAPI document F-57 requires."""
|
|
241
|
+
return EchoActError(code).to_payload("req_example")
|