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
echoact/jobs/request.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""What a generation request is, and what makes one valid.
|
|
2
|
+
|
|
3
|
+
Every entry path validates here. F-37 requires the GUI, REST, and MCP to
|
|
4
|
+
apply the same limits and the same support policy, and the cheapest way to
|
|
5
|
+
guarantee that is to give them one function rather than three that agree
|
|
6
|
+
today.
|
|
7
|
+
|
|
8
|
+
Nothing in this module touches the engine, the database, or the disk: it
|
|
9
|
+
answers "would this be accepted?", which is exactly what F-88's pre-flight
|
|
10
|
+
estimate needs and what the job engine needs before it takes the slot.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..domain import (
|
|
19
|
+
Gender,
|
|
20
|
+
JobKind,
|
|
21
|
+
Language,
|
|
22
|
+
RequestPath,
|
|
23
|
+
RetentionMode,
|
|
24
|
+
Segment,
|
|
25
|
+
SpeakingStyle,
|
|
26
|
+
VoiceSettings,
|
|
27
|
+
)
|
|
28
|
+
from ..errors import Code, EchoActError
|
|
29
|
+
from ..models.manifest import Manifest, ModelEntry
|
|
30
|
+
from ..policy import (
|
|
31
|
+
BOUNDED_WAIT_CEILING_S,
|
|
32
|
+
ESTIMATE_REAL_TIME_FACTOR,
|
|
33
|
+
MAX_INPUT_CODEPOINTS,
|
|
34
|
+
)
|
|
35
|
+
from ..text.segment import estimate_seconds, segment_text
|
|
36
|
+
from ..util import ids
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class JobRequest:
|
|
41
|
+
"""One accepted-or-refused generation request.
|
|
42
|
+
|
|
43
|
+
``idempotency_key`` is not optional. A.3 records why: with a single
|
|
44
|
+
generation slot, a client that retries after a slow or dropped response
|
|
45
|
+
would otherwise queue a second rendering of the same text behind the
|
|
46
|
+
first, and automated clients retry as a matter of course. The GUI mints
|
|
47
|
+
one per press for the same reason -- a double-click is a retry too.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
text: str
|
|
51
|
+
settings: VoiceSettings
|
|
52
|
+
request_path: RequestPath
|
|
53
|
+
owner_client_id: str
|
|
54
|
+
idempotency_key: str
|
|
55
|
+
kind: JobKind = JobKind.SPEECH
|
|
56
|
+
retention: RetentionMode = RetentionMode.ONE_OFF
|
|
57
|
+
client_label: str | None = None
|
|
58
|
+
#: F-88. ``None`` means answer as soon as the job is accepted.
|
|
59
|
+
wait_s: float | None = None
|
|
60
|
+
|
|
61
|
+
def digest(self) -> str:
|
|
62
|
+
"""F-49's request-match discriminator.
|
|
63
|
+
|
|
64
|
+
Hashed rather than stored: 4.2 keeps "only the information needed
|
|
65
|
+
for duplicate prevention ... with no source text", so the record
|
|
66
|
+
must be able to tell "same content" from "different content"
|
|
67
|
+
without holding the content.
|
|
68
|
+
"""
|
|
69
|
+
from ..db.store import request_match_digest
|
|
70
|
+
|
|
71
|
+
return request_match_digest(
|
|
72
|
+
self.kind.value,
|
|
73
|
+
self.text,
|
|
74
|
+
repr(sorted(self.settings.to_dict().items())),
|
|
75
|
+
self.retention.value,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Estimate:
|
|
81
|
+
"""F-88's pre-flight answer.
|
|
82
|
+
|
|
83
|
+
Explicitly approximate, and derived from the throughput A.5 measured
|
|
84
|
+
rather than from a trial synthesis: the requirement says an estimate
|
|
85
|
+
must not create a job or load a model.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
valid: bool
|
|
89
|
+
codepoints: int
|
|
90
|
+
segment_count: int
|
|
91
|
+
audio_ms: int
|
|
92
|
+
synthesis_ms: int
|
|
93
|
+
first_audio_ms: int
|
|
94
|
+
slot_free: bool
|
|
95
|
+
model_ready: bool
|
|
96
|
+
approximate: bool = True
|
|
97
|
+
problems: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
|
98
|
+
|
|
99
|
+
def to_dict(self) -> dict[str, Any]:
|
|
100
|
+
return {
|
|
101
|
+
"valid": self.valid,
|
|
102
|
+
"codepoints": self.codepoints,
|
|
103
|
+
"segment_count": self.segment_count,
|
|
104
|
+
"audio_ms": self.audio_ms,
|
|
105
|
+
"synthesis_ms": self.synthesis_ms,
|
|
106
|
+
"first_audio_ms": self.first_audio_ms,
|
|
107
|
+
"slot_free": self.slot_free,
|
|
108
|
+
"model_ready": self.model_ready,
|
|
109
|
+
"approximate": self.approximate,
|
|
110
|
+
"problems": list(self.problems),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_text(text: str) -> str:
|
|
115
|
+
"""F-03. Returns the text unchanged when it is acceptable.
|
|
116
|
+
|
|
117
|
+
Whitespace-only input is refused rather than trimmed: A.5 found the
|
|
118
|
+
engine raising on it, so this is the guard the requirement says it is,
|
|
119
|
+
and trimming would quietly change the offsets every segment range is
|
|
120
|
+
measured against.
|
|
121
|
+
"""
|
|
122
|
+
if not text or not text.strip():
|
|
123
|
+
raise EchoActError(Code.INPUT_EMPTY)
|
|
124
|
+
if len(text) > MAX_INPUT_CODEPOINTS:
|
|
125
|
+
raise EchoActError(
|
|
126
|
+
Code.INPUT_TOO_LONG,
|
|
127
|
+
detail={"codepoints": len(text), "limit": MAX_INPUT_CODEPOINTS},
|
|
128
|
+
)
|
|
129
|
+
return text
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def validate_settings(settings: VoiceSettings, manifest: Manifest) -> ModelEntry:
|
|
133
|
+
"""F-04 through F-08.
|
|
134
|
+
|
|
135
|
+
Nothing is substituted. F-54 is explicit that unknown models and
|
|
136
|
+
options are not arbitrarily replaced, so every mismatch is an error
|
|
137
|
+
naming what was wrong rather than a silent correction.
|
|
138
|
+
"""
|
|
139
|
+
settings.validate() # tempo range, F-07
|
|
140
|
+
|
|
141
|
+
if not manifest.has(settings.model_id):
|
|
142
|
+
raise EchoActError(
|
|
143
|
+
Code.MODEL_UNKNOWN,
|
|
144
|
+
detail={"model_id": settings.model_id, "known": list(manifest.model_ids)},
|
|
145
|
+
)
|
|
146
|
+
entry = manifest.get(settings.model_id)
|
|
147
|
+
|
|
148
|
+
if settings.language is not Language.AUTO and not entry.supports_language(settings.language):
|
|
149
|
+
raise EchoActError(
|
|
150
|
+
Code.LANGUAGE_UNKNOWN,
|
|
151
|
+
detail={
|
|
152
|
+
"language": settings.language.value,
|
|
153
|
+
"supported": [lang.value for lang in entry.languages],
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
if not isinstance(settings.style, SpeakingStyle):
|
|
157
|
+
raise EchoActError(Code.STYLE_UNKNOWN, detail={"style": str(settings.style)})
|
|
158
|
+
|
|
159
|
+
voice = next((v for v in entry.voices if v.voice_id == settings.voice_id), None)
|
|
160
|
+
if voice is None:
|
|
161
|
+
raise EchoActError(
|
|
162
|
+
Code.VOICE_UNKNOWN,
|
|
163
|
+
detail={
|
|
164
|
+
"voice_id": settings.voice_id,
|
|
165
|
+
"available": [v.voice_id for v in entry.voices],
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
if voice.gender is not settings.gender:
|
|
169
|
+
# F-06 selects a gender and then a voice within it; a request whose
|
|
170
|
+
# two halves disagree is a mistake to report, not one to resolve by
|
|
171
|
+
# picking a side.
|
|
172
|
+
raise EchoActError(
|
|
173
|
+
Code.VOICE_GENDER_MISMATCH,
|
|
174
|
+
detail={"voice_id": voice.voice_id, "voice_gender": voice.gender.value},
|
|
175
|
+
)
|
|
176
|
+
return entry
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def validate_request(request: JobRequest, manifest: Manifest) -> ModelEntry:
|
|
180
|
+
"""Everything a request must satisfy before it can take the slot."""
|
|
181
|
+
if request.kind is None:
|
|
182
|
+
raise EchoActError(Code.JOB_KIND_MISSING)
|
|
183
|
+
if not isinstance(request.kind, JobKind):
|
|
184
|
+
raise EchoActError(Code.JOB_KIND_UNKNOWN, detail={"kind": str(request.kind)})
|
|
185
|
+
if not request.idempotency_key or not request.idempotency_key.strip():
|
|
186
|
+
raise EchoActError(Code.IDEMPOTENCY_KEY_MISSING)
|
|
187
|
+
if request.wait_s is not None and request.wait_s < 0:
|
|
188
|
+
raise EchoActError(
|
|
189
|
+
Code.INTERNAL, "A wait cannot be negative.", detail={"wait_s": request.wait_s}
|
|
190
|
+
)
|
|
191
|
+
validate_text(request.text)
|
|
192
|
+
return validate_settings(request.settings, manifest)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def clamp_wait(wait_s: float | None, *, ceiling_s: float = BOUNDED_WAIT_CEILING_S) -> float:
|
|
196
|
+
"""4.1's bounded wait.
|
|
197
|
+
|
|
198
|
+
The service never waits longer than the caller asked *and* never longer
|
|
199
|
+
than the owner's ceiling, so this takes the smaller of the two rather
|
|
200
|
+
than substituting the default for an out-of-range value.
|
|
201
|
+
"""
|
|
202
|
+
if wait_s is None:
|
|
203
|
+
return 0.0
|
|
204
|
+
return max(0.0, min(float(wait_s), ceiling_s))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def plan_segments(text: str, settings: VoiceSettings) -> list[Segment]:
|
|
208
|
+
"""F-81, with fresh identifiers so the plan can be stored as-is."""
|
|
209
|
+
segments = segment_text(text, settings)
|
|
210
|
+
for seg in segments:
|
|
211
|
+
if not seg.segment_id:
|
|
212
|
+
seg.segment_id = ids.segment_id()
|
|
213
|
+
return segments
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def estimate(
|
|
217
|
+
text: str,
|
|
218
|
+
settings: VoiceSettings,
|
|
219
|
+
manifest: Manifest,
|
|
220
|
+
*,
|
|
221
|
+
slot_free: bool,
|
|
222
|
+
model_ready: bool,
|
|
223
|
+
real_time_factor: float = ESTIMATE_REAL_TIME_FACTOR,
|
|
224
|
+
) -> Estimate:
|
|
225
|
+
"""F-88's pre-flight estimate.
|
|
226
|
+
|
|
227
|
+
Answers even when the request is invalid: a caller asking "would this
|
|
228
|
+
work?" is best served by the reasons it would not, all of them, rather
|
|
229
|
+
than by the first exception. That is also why validation problems are
|
|
230
|
+
collected instead of raised.
|
|
231
|
+
"""
|
|
232
|
+
problems: list[dict[str, Any]] = []
|
|
233
|
+
for check in (
|
|
234
|
+
lambda: validate_text(text),
|
|
235
|
+
lambda: validate_settings(settings, manifest),
|
|
236
|
+
):
|
|
237
|
+
try:
|
|
238
|
+
check()
|
|
239
|
+
except EchoActError as exc:
|
|
240
|
+
problems.append({"code": exc.code.value, "message": exc.message, **exc.detail})
|
|
241
|
+
|
|
242
|
+
if problems:
|
|
243
|
+
return Estimate(
|
|
244
|
+
valid=False,
|
|
245
|
+
codepoints=len(text),
|
|
246
|
+
segment_count=0,
|
|
247
|
+
audio_ms=0,
|
|
248
|
+
synthesis_ms=0,
|
|
249
|
+
first_audio_ms=0,
|
|
250
|
+
slot_free=slot_free,
|
|
251
|
+
model_ready=model_ready,
|
|
252
|
+
problems=tuple(problems),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
segments = segment_text(text, settings)
|
|
256
|
+
tempo = settings.tempo
|
|
257
|
+
audio_s = 0.0
|
|
258
|
+
first_s = 0.0
|
|
259
|
+
for i, seg in enumerate(segments):
|
|
260
|
+
seconds = estimate_seconds(seg.spoken_text, seg.language, tempo)
|
|
261
|
+
seconds += seg.trailing_silence_ms / 1000.0
|
|
262
|
+
audio_s += seconds
|
|
263
|
+
if i == 0:
|
|
264
|
+
first_s = seconds
|
|
265
|
+
|
|
266
|
+
synthesis_s = audio_s * real_time_factor
|
|
267
|
+
# Time to the first sound, which is what F-12 makes the interesting
|
|
268
|
+
# number: the first segment has to be synthesised before anything plays,
|
|
269
|
+
# and a model that is not resident has to be loaded first. The load is
|
|
270
|
+
# not included here -- F-88 says an estimate never waits on model
|
|
271
|
+
# preparation, so quoting it would describe a different operation.
|
|
272
|
+
first_audio_s = first_s * real_time_factor
|
|
273
|
+
|
|
274
|
+
return Estimate(
|
|
275
|
+
valid=True,
|
|
276
|
+
codepoints=len(text),
|
|
277
|
+
segment_count=len(segments),
|
|
278
|
+
audio_ms=int(round(audio_s * 1000)),
|
|
279
|
+
synthesis_ms=int(round(synthesis_s * 1000)),
|
|
280
|
+
first_audio_ms=int(round(first_audio_s * 1000)),
|
|
281
|
+
slot_free=slot_free,
|
|
282
|
+
model_ready=model_ready,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def resolve_gender_voice(entry: ModelEntry, gender: Gender, voice_id: str | None) -> str:
|
|
287
|
+
"""The first voice of a gender, for a caller that named only a gender.
|
|
288
|
+
|
|
289
|
+
Used by the GUI when the user switches gender, never to paper over an
|
|
290
|
+
invalid request: F-54 forbids substituting an unknown option, and this
|
|
291
|
+
is only reached when no voice was named at all.
|
|
292
|
+
"""
|
|
293
|
+
voices = entry.voices_for(gender)
|
|
294
|
+
if not voices:
|
|
295
|
+
raise EchoActError(Code.VOICE_UNKNOWN, detail={"gender": gender.value})
|
|
296
|
+
if voice_id:
|
|
297
|
+
for v in voices:
|
|
298
|
+
if v.voice_id == voice_id:
|
|
299
|
+
return v.voice_id
|
|
300
|
+
return voices[0].voice_id
|
echoact/mcp/__init__.py
ADDED
|
File without changes
|
echoact/mcp/__main__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Entry point for the MCP server process.
|
|
2
|
+
|
|
3
|
+
Started by the MCP client, per F-58. It never starts EchoAct -- F-52 is
|
|
4
|
+
explicit -- and it exits with a message rather than waiting when the app
|
|
5
|
+
is not there to talk to.
|
|
6
|
+
|
|
7
|
+
Diagnostics go to stderr, which the revision this implements requires:
|
|
8
|
+
its logging feature is deprecated and §2.11 says so.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from ..errors import EchoActError
|
|
16
|
+
from .client import RestClient
|
|
17
|
+
from .config import from_environment
|
|
18
|
+
from .server import build, preflight
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
23
|
+
try:
|
|
24
|
+
connection = from_environment(args)
|
|
25
|
+
except EchoActError as exc:
|
|
26
|
+
print(f"[echoact-mcp] {exc.code.value}: {exc.message}", file=sys.stderr, flush=True)
|
|
27
|
+
return 2
|
|
28
|
+
|
|
29
|
+
rest = RestClient(connection)
|
|
30
|
+
try:
|
|
31
|
+
preflight(rest)
|
|
32
|
+
except EchoActError as exc:
|
|
33
|
+
# Distinct and non-retrying, per F-52. Exiting rather than
|
|
34
|
+
# serving is the honest answer: every tool would fail the same
|
|
35
|
+
# way, and a client that saw tools listed would reasonably expect
|
|
36
|
+
# them to work.
|
|
37
|
+
print(f"[echoact-mcp] {exc.code.value}: {exc.message}", file=sys.stderr, flush=True)
|
|
38
|
+
rest.close()
|
|
39
|
+
return 3
|
|
40
|
+
|
|
41
|
+
server = build(connection, client=rest)
|
|
42
|
+
try:
|
|
43
|
+
server.run(transport="stdio", show_banner=False)
|
|
44
|
+
finally:
|
|
45
|
+
rest.close()
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
raise SystemExit(main())
|
echoact/mcp/client.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""The MCP server's half of the loopback conversation.
|
|
2
|
+
|
|
3
|
+
F-58 makes the MCP server a client of the local REST service and nothing
|
|
4
|
+
else: no model, no database, no job state. So this is the whole of its
|
|
5
|
+
machinery, and its one interesting job is translating the ways the
|
|
6
|
+
conversation can fail into the distinct, non-retrying errors F-52 names:
|
|
7
|
+
|
|
8
|
+
* EchoAct is not running -- the connection is refused. Distinct because
|
|
9
|
+
the remedy is to start EchoAct, and because F-52 forbids this process
|
|
10
|
+
from starting it.
|
|
11
|
+
* REST is turned off -- there is no listener, or one that says so.
|
|
12
|
+
* MCP is disabled in EchoAct -- the service answers, but the owner has
|
|
13
|
+
not enabled this integration.
|
|
14
|
+
* The credential has expired or been revoked -- authentication fails for
|
|
15
|
+
a reason the user can act on, which is not the same as being wrong.
|
|
16
|
+
|
|
17
|
+
Every other failure keeps the service's own error code, because N-24
|
|
18
|
+
makes MCP a projection of REST and a behaviour difference between them a
|
|
19
|
+
defect. Re-deriving a code here would be exactly that difference.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from ..errors import Code, EchoActError, is_retryable
|
|
29
|
+
from .config import Connection
|
|
30
|
+
|
|
31
|
+
#: Codes that mean "this process cannot work until something changes",
|
|
32
|
+
#: as opposed to "try again". F-52 wants these reported as non-retrying.
|
|
33
|
+
TERMINAL_CODES = frozenset(
|
|
34
|
+
{
|
|
35
|
+
Code.APP_NOT_RUNNING,
|
|
36
|
+
Code.SERVICE_OFF,
|
|
37
|
+
Code.MCP_DISABLED,
|
|
38
|
+
Code.CREDENTIAL_EXPIRED,
|
|
39
|
+
Code.CREDENTIAL_REVOKED,
|
|
40
|
+
Code.UNAUTHENTICATED,
|
|
41
|
+
Code.HOST_NOT_ALLOWED,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RestClient:
|
|
47
|
+
"""A thin, synchronous client. Synchronous on purpose: this process
|
|
48
|
+
serves one stdio conversation and has nothing to overlap with."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, connection: Connection, transport: httpx.BaseTransport | None = None):
|
|
51
|
+
self.connection = connection
|
|
52
|
+
self._http = httpx.Client(
|
|
53
|
+
base_url=connection.api_root,
|
|
54
|
+
headers=connection.headers(),
|
|
55
|
+
timeout=connection.timeout_s,
|
|
56
|
+
transport=transport,
|
|
57
|
+
follow_redirects=False,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
self._http.close()
|
|
62
|
+
|
|
63
|
+
def __enter__(self) -> RestClient:
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, *exc: object) -> None:
|
|
67
|
+
self.close()
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def get(self, path: str, **params: Any) -> dict[str, Any]:
|
|
72
|
+
return self._json("GET", path, params={k: v for k, v in params.items() if v is not None})
|
|
73
|
+
|
|
74
|
+
def post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
75
|
+
return self._json("POST", path, json=body)
|
|
76
|
+
|
|
77
|
+
def get_bytes(self, path: str) -> tuple[bytes, str]:
|
|
78
|
+
"""Fetch a result's audio. Returns (bytes, media type).
|
|
79
|
+
|
|
80
|
+
The MCP server resolves a resource URI on the client's behalf
|
|
81
|
+
(F-60), so the bytes pass through this process rather than the
|
|
82
|
+
client being handed an address -- which is what lets §2.11 keep
|
|
83
|
+
local paths and tokenised URLs out of the protocol entirely.
|
|
84
|
+
"""
|
|
85
|
+
response = self._send("GET", path, headers={"Accept": "audio/wav"})
|
|
86
|
+
return response.content, response.headers.get("content-type", "audio/wav")
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _json(self, method: str, path: str, **kw: Any) -> dict[str, Any]:
|
|
91
|
+
response = self._send(method, path, **kw)
|
|
92
|
+
if not response.content:
|
|
93
|
+
return {}
|
|
94
|
+
try:
|
|
95
|
+
body = response.json()
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise EchoActError(
|
|
98
|
+
Code.INTERNAL, "EchoAct returned something that is not JSON.", cause=exc
|
|
99
|
+
) from exc
|
|
100
|
+
return body if isinstance(body, dict) else {"result": body}
|
|
101
|
+
|
|
102
|
+
def _send(self, method: str, path: str, **kw: Any) -> httpx.Response:
|
|
103
|
+
try:
|
|
104
|
+
response = self._http.request(method, path, **kw)
|
|
105
|
+
except httpx.ConnectError as exc:
|
|
106
|
+
# The refused connection is the signal, and it is the one case
|
|
107
|
+
# this process must never try to fix by starting anything.
|
|
108
|
+
raise EchoActError(
|
|
109
|
+
Code.APP_NOT_RUNNING,
|
|
110
|
+
"EchoAct is not running, or its local service is turned off. "
|
|
111
|
+
"Start EchoAct and check Settings; this server cannot start it.",
|
|
112
|
+
cause=exc,
|
|
113
|
+
) from exc
|
|
114
|
+
except httpx.TimeoutException as exc:
|
|
115
|
+
raise EchoActError(
|
|
116
|
+
Code.APP_NOT_RUNNING,
|
|
117
|
+
"EchoAct did not answer on the loopback address.",
|
|
118
|
+
cause=exc,
|
|
119
|
+
) from exc
|
|
120
|
+
except httpx.HTTPError as exc:
|
|
121
|
+
raise EchoActError(
|
|
122
|
+
Code.INTERNAL, f"The local request failed ({type(exc).__name__}).", cause=exc
|
|
123
|
+
) from exc
|
|
124
|
+
|
|
125
|
+
if response.status_code >= 400:
|
|
126
|
+
raise self._error(response)
|
|
127
|
+
return response
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _error(response: httpx.Response) -> EchoActError:
|
|
131
|
+
"""Carry the service's own code across, rather than inventing one."""
|
|
132
|
+
payload: dict[str, Any] = {}
|
|
133
|
+
try:
|
|
134
|
+
parsed = response.json()
|
|
135
|
+
if isinstance(parsed, dict):
|
|
136
|
+
payload = parsed
|
|
137
|
+
except ValueError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
raw = str(payload.get("code", "")).strip()
|
|
141
|
+
try:
|
|
142
|
+
code = Code(raw)
|
|
143
|
+
except ValueError:
|
|
144
|
+
code = _code_for_status(response.status_code)
|
|
145
|
+
|
|
146
|
+
message = str(payload.get("message", "")) or None
|
|
147
|
+
retry_after = payload.get("retry_after_s")
|
|
148
|
+
if retry_after is None and "Retry-After" in response.headers:
|
|
149
|
+
try:
|
|
150
|
+
retry_after = float(response.headers["Retry-After"])
|
|
151
|
+
except ValueError:
|
|
152
|
+
retry_after = None
|
|
153
|
+
detail = {k: v for k, v in payload.items() if k not in {"code", "message", "retry_after_s"}}
|
|
154
|
+
return EchoActError(
|
|
155
|
+
code,
|
|
156
|
+
message,
|
|
157
|
+
detail=detail or None,
|
|
158
|
+
retry_after_s=float(retry_after) if retry_after is not None else None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _code_for_status(status: int) -> Code:
|
|
163
|
+
"""Only reached when the answer carried no code of its own.
|
|
164
|
+
|
|
165
|
+
F-57 requires every EchoAct error to carry one, so this is the case
|
|
166
|
+
where something *else* is listening on the port. Statuses whose
|
|
167
|
+
meaning is unambiguous are mapped; the rest stay ``INTERNAL`` rather
|
|
168
|
+
than being guessed into a precise-looking code that would then be
|
|
169
|
+
wrong -- 422 in particular means "invalid voice settings" in this
|
|
170
|
+
contract and nothing at all in a stranger's.
|
|
171
|
+
"""
|
|
172
|
+
return {
|
|
173
|
+
401: Code.UNAUTHENTICATED,
|
|
174
|
+
403: Code.FORBIDDEN,
|
|
175
|
+
404: Code.NOT_FOUND,
|
|
176
|
+
410: Code.RESULT_EXPIRED,
|
|
177
|
+
413: Code.PAYLOAD_TOO_LARGE,
|
|
178
|
+
429: Code.RATE_LIMITED,
|
|
179
|
+
503: Code.SERVICE_OFF,
|
|
180
|
+
507: Code.STORAGE_FULL,
|
|
181
|
+
}.get(status, Code.INTERNAL)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def describe(error: EchoActError) -> dict[str, Any]:
|
|
185
|
+
"""What a tool returns when the call failed.
|
|
186
|
+
|
|
187
|
+
Keeps the service's code and adds the one thing an automated caller
|
|
188
|
+
most needs and F-57 already decided: whether trying again can work.
|
|
189
|
+
"""
|
|
190
|
+
return {
|
|
191
|
+
"error": {
|
|
192
|
+
"code": error.code.value,
|
|
193
|
+
"message": error.message,
|
|
194
|
+
"retryable": error.retryable and error.code not in TERMINAL_CODES,
|
|
195
|
+
**({"retry_after_s": error.retry_after_s} if error.retry_after_s else {}),
|
|
196
|
+
**({"detail": error.detail} if error.detail else {}),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def is_terminal(error: EchoActError) -> bool:
|
|
202
|
+
return error.code in TERMINAL_CODES or not is_retryable(error.code)
|
echoact/mcp/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""How the MCP server is told where EchoAct is and who it is.
|
|
2
|
+
|
|
3
|
+
F-58 puts the connection details in the MCP *client's* configuration and
|
|
4
|
+
has the app hand them to the user, so this process reads them from its
|
|
5
|
+
environment and its arguments and holds nothing of its own. There is no
|
|
6
|
+
config file to find, no discovery, and no fallback that would let it work
|
|
7
|
+
without a credential -- N-31's fail-closed rule reaches here too.
|
|
8
|
+
|
|
9
|
+
The credential is read from the environment rather than from a command
|
|
10
|
+
line by preference: an argument is visible in the process list to every
|
|
11
|
+
program on the machine, and N-17 keeps tokens out of places they can be
|
|
12
|
+
read from. A ``--token`` argument is still accepted, because some MCP
|
|
13
|
+
client configurations cannot set an environment variable, and the
|
|
14
|
+
docstring is the place to say which is worse.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from ..errors import Code, EchoActError
|
|
23
|
+
from ..policy import REST_HOST, REST_PORT_DEFAULT
|
|
24
|
+
|
|
25
|
+
ENV_URL = "ECHOACT_URL"
|
|
26
|
+
ENV_TOKEN = "ECHOACT_TOKEN"
|
|
27
|
+
ENV_TIMEOUT = "ECHOACT_TIMEOUT_S"
|
|
28
|
+
|
|
29
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Connection:
|
|
34
|
+
"""Everything this process needs, and nothing it could invent."""
|
|
35
|
+
|
|
36
|
+
base_url: str
|
|
37
|
+
token: str
|
|
38
|
+
timeout_s: float = DEFAULT_TIMEOUT_S
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def api_root(self) -> str:
|
|
42
|
+
return f"{self.base_url.rstrip('/')}/api/v1"
|
|
43
|
+
|
|
44
|
+
def headers(self) -> dict[str, str]:
|
|
45
|
+
# The Host header is what the service validates before it looks at
|
|
46
|
+
# the credential (N-17), so it has to be the loopback name the
|
|
47
|
+
# service expects rather than whatever httpx would infer.
|
|
48
|
+
return {
|
|
49
|
+
"Authorization": f"Bearer {self.token}",
|
|
50
|
+
"Accept": "application/json",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_url() -> str:
|
|
55
|
+
return f"http://{REST_HOST}:{REST_PORT_DEFAULT}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def from_environment(argv: list[str] | None = None) -> Connection:
|
|
59
|
+
"""Read the connection, or refuse to start.
|
|
60
|
+
|
|
61
|
+
A missing credential is a configuration error the user must fix, not a
|
|
62
|
+
condition to retry: F-52 wants distinct, non-retrying errors for the
|
|
63
|
+
ways this process can be unusable, and starting anonymously is not an
|
|
64
|
+
option N-31 leaves open.
|
|
65
|
+
"""
|
|
66
|
+
args = list(argv or [])
|
|
67
|
+
token = os.environ.get(ENV_TOKEN, "")
|
|
68
|
+
url = os.environ.get(ENV_URL, "") or default_url()
|
|
69
|
+
|
|
70
|
+
for i, arg in enumerate(args):
|
|
71
|
+
if arg == "--token" and i + 1 < len(args):
|
|
72
|
+
token = args[i + 1]
|
|
73
|
+
elif arg.startswith("--token="):
|
|
74
|
+
token = arg.split("=", 1)[1]
|
|
75
|
+
elif arg == "--url" and i + 1 < len(args):
|
|
76
|
+
url = args[i + 1]
|
|
77
|
+
elif arg.startswith("--url="):
|
|
78
|
+
url = arg.split("=", 1)[1]
|
|
79
|
+
|
|
80
|
+
if not token:
|
|
81
|
+
raise EchoActError(
|
|
82
|
+
Code.UNAUTHENTICATED,
|
|
83
|
+
f"No EchoAct credential. Set {ENV_TOKEN} in this server's configuration; "
|
|
84
|
+
"EchoAct shows the value once, under Settings.",
|
|
85
|
+
)
|
|
86
|
+
if not _is_loopback(url):
|
|
87
|
+
# N-17 and Section 7: EchoAct is never reachable off this machine,
|
|
88
|
+
# so a URL that points elsewhere is a misconfiguration to report
|
|
89
|
+
# rather than an address to try.
|
|
90
|
+
raise EchoActError(
|
|
91
|
+
Code.HOST_NOT_ALLOWED,
|
|
92
|
+
f"EchoAct only listens on the loopback address; {url!r} is not one.",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
timeout = DEFAULT_TIMEOUT_S
|
|
96
|
+
raw = os.environ.get(ENV_TIMEOUT)
|
|
97
|
+
if raw:
|
|
98
|
+
try:
|
|
99
|
+
timeout = max(1.0, float(raw))
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
return Connection(base_url=url, token=token, timeout_s=timeout)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_loopback(url: str) -> bool:
|
|
106
|
+
from urllib.parse import urlparse
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
host = (urlparse(url).hostname or "").lower()
|
|
110
|
+
except ValueError:
|
|
111
|
+
return False
|
|
112
|
+
return host in {"127.0.0.1", "localhost", "::1"}
|