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,1125 @@
1
+ """Section 2.10's twelve operations. Twelve, and no thirteenth.
2
+
3
+ The contract table is the public functional surface, so this module registers
4
+ exactly the paths it lists. Anything a client might also find useful --
5
+ deleting a job, editing a document, querying the database -- is absent on
6
+ purpose: F-56 says external document editing, deletion, and full-database
7
+ queries are not supported, and F-61 says MCP adds no capability REST does not
8
+ already expose, which only means anything if REST's own surface is closed.
9
+
10
+ Two constraints shape how the handlers are written.
11
+
12
+ * **N-22.** Acceptance, query, cancellation, and estimate answer within one
13
+ second at p95 and independently of model loading and generation. So no
14
+ handler blocks on the engine. ``submit`` returns as soon as the job is
15
+ accepted, ``cancel`` hands the kill to another thread and answers 202, and
16
+ the only wait in the file is F-88's, which the caller asked for and which
17
+ N-22 excludes from its figure.
18
+ * **N-21.** No list query and no audio retrieval loads an unbounded amount
19
+ into memory. Pages are bounded by 4.1, segment and result rows carry
20
+ counts rather than audio, and both audio routes stream from disk.
21
+
22
+ Capability mapping, from F-61's three separately granted permissions:
23
+
24
+ =========================================== =====================
25
+ Operation Capability
26
+ =========================================== =====================
27
+ status, models (authentication only)
28
+ estimate, create, job state, cancel ``generate``
29
+ segments, segment audio, audio, result ``read_results``
30
+ history list, source-text snapshot ``read_history``
31
+ =========================================== =====================
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from fastapi import APIRouter, Query, Request, Response
40
+ from fastapi.exceptions import RequestValidationError
41
+ from pydantic import ValidationError
42
+ from python_multipart.exceptions import FormParserError
43
+ from starlette.concurrency import run_in_threadpool
44
+ from starlette.datastructures import FormData
45
+ from starlette.exceptions import HTTPException as StarletteHTTPException
46
+ from starlette.formparsers import MultiPartException
47
+ from starlette.responses import StreamingResponse
48
+
49
+ from ..audio import wav
50
+ from ..config.budget import resolve_budget_from_system
51
+ from ..db.store import JobSummary
52
+ from ..domain import (
53
+ Budget,
54
+ Capability,
55
+ Gender,
56
+ Job,
57
+ JobKind,
58
+ JobState,
59
+ Language,
60
+ RequestPath,
61
+ RetentionMode,
62
+ Segment,
63
+ SpeakingStyle,
64
+ VoiceSettings,
65
+ )
66
+ from ..errors import Code, EchoActError, is_retryable
67
+ from ..jobs.engine import BUSY_RETRY_AFTER_S
68
+ from ..jobs.request import JobRequest, clamp_wait, estimate
69
+ from ..models.registry import ModelState
70
+ from ..policy import (
71
+ API_VERSION,
72
+ BOUNDED_WAIT_DEFAULT_S,
73
+ IDEMPOTENCY_TTL_S,
74
+ LIST_PAGE_DEFAULT,
75
+ LIST_PAGE_MAX,
76
+ MAX_CONCURRENT_GENERATION,
77
+ MAX_IMPORT_FILE_BYTES,
78
+ MAX_INPUT_CODEPOINTS,
79
+ MAX_REQUEST_BODY_BYTES,
80
+ ONEOFF_RESULT_TTL_S,
81
+ RATE_GENERATION_PER_MIN,
82
+ RATE_OTHER_PER_MIN,
83
+ TEMPO_DEFAULT,
84
+ TEMPO_MAX,
85
+ TEMPO_MIN,
86
+ )
87
+ from ..text.loader import load_bytes
88
+ from ..text.sniff import SELECTABLE_ENCODINGS, SUPPORTED_FORMATS
89
+ from ..util import ids
90
+ from .deps import (
91
+ CANCEL_ACK_GRACE_S,
92
+ ServiceContext,
93
+ credential_of,
94
+ iter_file,
95
+ owner_scope,
96
+ require_capability,
97
+ require_job,
98
+ resolve_managed_audio,
99
+ )
100
+ from .schemas import (
101
+ BudgetOut,
102
+ CapabilitiesOut,
103
+ CreateJobRequest,
104
+ EstimateOut,
105
+ EstimateRequest,
106
+ GenerationStateOut,
107
+ IntegrityOut,
108
+ JobError,
109
+ JobOut,
110
+ JobPage,
111
+ JobProgress,
112
+ JobTextOut,
113
+ LicenseOut,
114
+ MinimumBudgetOut,
115
+ ModelOut,
116
+ ModelsOut,
117
+ ResourcePolicyOut,
118
+ ResultOut,
119
+ SegmentOut,
120
+ SegmentsOut,
121
+ StatusOut,
122
+ VoiceChoice,
123
+ VoiceOut,
124
+ )
125
+
126
+ #: What a caller is told to wait for something that is merely not finished
127
+ #: yet. ``echoact.policy`` fixes no number for it, so the job engine's own
128
+ #: busy hint is reused rather than a second one invented: both answer "the one
129
+ #: generation slot is working, come back shortly".
130
+ NOT_READY_RETRY_AFTER_S = BUSY_RETRY_AFTER_S
131
+
132
+ AUDIO_MEDIA_TYPE = "audio/wav"
133
+
134
+ #: N-19 and F-60: a result belongs to one owner, so no shared intermediary may
135
+ #: keep a copy and no browser may replay one from its cache.
136
+ _AUDIO_HEADERS = {"Cache-Control": "private, no-store", "Accept-Ranges": "none"}
137
+
138
+
139
+ def build_router(context: ServiceContext) -> APIRouter:
140
+ """Register the twelve operations against one service context."""
141
+ router = APIRouter()
142
+
143
+ # ==================================================================
144
+ # F-53 -- service and capability query
145
+ # ==================================================================
146
+
147
+ @router.get(
148
+ "/status",
149
+ response_model=StatusOut,
150
+ summary="Service, version, supported capabilities, resource policy",
151
+ )
152
+ def get_status(request: Request) -> StatusOut:
153
+ """F-53's uptime, version, capabilities, and *actual* resource policy.
154
+
155
+ The budget reported is the one a job would run under right now, which
156
+ F-21 and N-04 make a function of the machine at this instant rather
157
+ than of the owner's setting alone. A machine that cannot currently
158
+ grant F-23's floor is reported as such instead of failing the query:
159
+ a caller asking whether the service is usable is exactly the caller
160
+ that needs to be told it is not.
161
+ """
162
+ credential = credential_of(request)
163
+ settings = context.settings
164
+ budget, budget_reason = _current_budget(context)
165
+ engine = context.engine
166
+ current = engine.current()
167
+ mine = bool(
168
+ current is not None
169
+ and (credential.is_owner or current.owner_client_id == credential.client_id)
170
+ )
171
+ granted = sorted(c.value for c in credential.effective_capabilities)
172
+ return StatusOut(
173
+ service="echoact",
174
+ version=_app_version(),
175
+ api_version=API_VERSION,
176
+ state="draining" if context.draining else "running",
177
+ started_at=context.started_at,
178
+ uptime_s=max(0.0, ids.now() - context.started_at),
179
+ bind_host=context.host,
180
+ bind_port=context.port,
181
+ mcp_enabled=bool(settings.mcp_enabled),
182
+ client_id=credential.client_id,
183
+ client_name=credential.name,
184
+ credential_expires_at=credential.expires_at,
185
+ capabilities=CapabilitiesOut(
186
+ granted=granted,
187
+ job_kinds=list(JobKind),
188
+ languages=list(Language),
189
+ styles=list(SpeakingStyle),
190
+ input_formats=list(SUPPORTED_FORMATS),
191
+ bounded_wait=True,
192
+ estimate=True,
193
+ upload=True,
194
+ history=credential.has_capability(Capability.READ_HISTORY),
195
+ results=credential.has_capability(Capability.READ_RESULTS),
196
+ generation=credential.has_capability(Capability.GENERATE),
197
+ ),
198
+ resource_policy=ResourcePolicyOut(
199
+ max_input_codepoints=MAX_INPUT_CODEPOINTS,
200
+ max_upload_bytes=MAX_IMPORT_FILE_BYTES,
201
+ max_request_body_bytes=MAX_REQUEST_BODY_BYTES,
202
+ concurrent_generation=MAX_CONCURRENT_GENERATION,
203
+ generation_requests_per_minute=RATE_GENERATION_PER_MIN,
204
+ other_requests_per_minute=RATE_OTHER_PER_MIN,
205
+ list_page_default=LIST_PAGE_DEFAULT,
206
+ list_page_max=LIST_PAGE_MAX,
207
+ bounded_wait_default_s=BOUNDED_WAIT_DEFAULT_S,
208
+ bounded_wait_max_s=context.bounded_wait_ceiling_s,
209
+ one_off_result_ttl_s=ONEOFF_RESULT_TTL_S,
210
+ idempotency_ttl_s=IDEMPOTENCY_TTL_S,
211
+ tempo_min=TEMPO_MIN,
212
+ tempo_max=TEMPO_MAX,
213
+ applied_budget=_budget_out(budget),
214
+ budget_unavailable_reason=budget_reason,
215
+ ),
216
+ generation=GenerationStateOut(
217
+ busy=bool(engine.busy),
218
+ current_job_id=current.job_id if (current is not None and mine) else None,
219
+ current_job_is_mine=mine,
220
+ ),
221
+ )
222
+
223
+ # ==================================================================
224
+ # F-53 -- models and voice/setting choices
225
+ # ==================================================================
226
+
227
+ @router.get(
228
+ "/models",
229
+ response_model=ModelsOut,
230
+ summary="Models and voice/setting choices",
231
+ )
232
+ def get_models(request: Request) -> ModelsOut:
233
+ """F-53's model list, with every voice described.
234
+
235
+ The description is not decoration: F-53 says a bare identifier gives a
236
+ caller that is not a person no basis for choosing a voice, so the
237
+ manifest's own text travels here and to the GUI alike.
238
+
239
+ A model that is not downloaded reports ``state`` and a reason and is
240
+ still listed. F-04 forbids hiding it or substituting another, and
241
+ F-53 requires an undownloaded model to be distinguishable from a
242
+ server fault -- which it is, because this answers 200 either way and
243
+ says which.
244
+ """
245
+ credential_of(request)
246
+ budget, _ = _current_budget(context)
247
+ statuses = context.registry.statuses(budget)
248
+ manifest = context.manifest
249
+ models: list[ModelOut] = []
250
+ for status in statuses:
251
+ entry = manifest.get(status.model_id)
252
+ models.append(
253
+ ModelOut(
254
+ model_id=status.model_id,
255
+ display_name=status.display_name,
256
+ state=status.state.value,
257
+ ready=status.state is ModelState.READY,
258
+ runnable=status.runnable,
259
+ unavailable_reason=status.unavailable_reason,
260
+ sample_rate=status.sample_rate,
261
+ languages=[Language(code) for code in status.languages],
262
+ styles=list(SpeakingStyle),
263
+ voices=[
264
+ VoiceChoice(
265
+ voice_id=voice.voice_id,
266
+ display_name=voice.display_name,
267
+ gender=voice.gender,
268
+ description=voice.description,
269
+ )
270
+ for voice in entry.voices
271
+ ],
272
+ minimum_budget=MinimumBudgetOut(
273
+ memory_bytes=status.minimum_memory_bytes,
274
+ cpu_percent=status.minimum_cpu_percent,
275
+ ),
276
+ bytes_total=status.bytes_total,
277
+ bytes_present=status.bytes_present,
278
+ license=LicenseOut(
279
+ name=status.license_name,
280
+ acceptance_required=status.license_acceptance_required,
281
+ accepted=status.license_accepted,
282
+ ),
283
+ download_authorised=status.download_authorised,
284
+ )
285
+ )
286
+ return ModelsOut(
287
+ models=models,
288
+ languages=list(Language),
289
+ styles=list(SpeakingStyle),
290
+ genders=list(Gender),
291
+ input_formats=list(SUPPORTED_FORMATS),
292
+ encodings=list(SELECTABLE_ENCODINGS),
293
+ tempo_min=TEMPO_MIN,
294
+ tempo_max=TEMPO_MAX,
295
+ tempo_default=TEMPO_DEFAULT,
296
+ )
297
+
298
+ # ==================================================================
299
+ # F-88 -- pre-flight estimate
300
+ # ==================================================================
301
+
302
+ @router.post(
303
+ "/estimate",
304
+ response_model=EstimateOut,
305
+ summary="Validate text and settings and estimate the work, without creating a job",
306
+ )
307
+ def post_estimate(request: Request, body: EstimateRequest) -> EstimateOut:
308
+ """F-88's pre-flight. Creates no job, loads no model, synthesises nothing.
309
+
310
+ An invalid request still answers 200 with ``valid: false`` and every
311
+ problem it found. F-88 says an estimate returns "validation results",
312
+ and a caller asking "would this work?" is best served by all the
313
+ reasons it would not rather than by the first exception -- which is
314
+ also why the model's readiness is reported here instead of being a
315
+ refusal.
316
+ """
317
+ require_capability(context, request, Capability.GENERATE)
318
+ settings = _voice_settings(body.voice)
319
+ report = estimate(
320
+ body.text,
321
+ settings,
322
+ context.manifest,
323
+ slot_free=not context.engine.busy,
324
+ model_ready=_model_ready(context, settings.model_id),
325
+ )
326
+ return EstimateOut(**report.to_dict())
327
+
328
+ # ==================================================================
329
+ # F-54 -- create a job
330
+ # ==================================================================
331
+
332
+ @router.post(
333
+ "/jobs",
334
+ response_model=JobOut,
335
+ status_code=202,
336
+ summary="Create a job from text or an upload, with a required duplicate-prevention key",
337
+ responses={
338
+ 200: {
339
+ "model": JobOut,
340
+ "description": (
341
+ "An existing job, returned unchanged for a repeated duplicate-prevention "
342
+ "key (F-49), or a job that reached a terminal state inside the requested "
343
+ "wait (F-88)."
344
+ ),
345
+ },
346
+ 202: {"description": "Accepted. The job continues; poll or fetch the result."},
347
+ },
348
+ openapi_extra={"requestBody": _create_job_request_body()},
349
+ )
350
+ async def post_jobs(request: Request, response: Response) -> JobOut:
351
+ """F-54's generation request, with F-49's key and F-88's optional wait.
352
+
353
+ Async only because the body may be a multipart upload and reading it
354
+ is the one part that belongs on the event loop; everything that can
355
+ block -- validation, the store, the engine -- is handed to a worker
356
+ thread, so a request that opts into a ten-second wait does not stall
357
+ the nine other things N-22 promises to answer within one second.
358
+ """
359
+ body, upload, filename = await _read_create_request(request)
360
+ return await run_in_threadpool(_create_job, context, request, response, body, upload, filename)
361
+
362
+ # ==================================================================
363
+ # F-56 -- history
364
+ # ==================================================================
365
+
366
+ @router.get(
367
+ "/jobs",
368
+ response_model=JobPage,
369
+ summary="Authorised retained history query",
370
+ )
371
+ def get_jobs(
372
+ request: Request,
373
+ limit: int = Query(
374
+ LIST_PAGE_DEFAULT,
375
+ description=(
376
+ f"Items per page. Clamped to 1..{LIST_PAGE_MAX} rather than refused, "
377
+ "because 4.1 fixes the page size regardless of what is asked."
378
+ ),
379
+ ),
380
+ offset: int = Query(0, description="Rows to skip, oldest-last ordering by creation time."),
381
+ state: JobState | None = Query(None),
382
+ model_id: str | None = Query(None),
383
+ retention: RetentionMode | None = Query(
384
+ None,
385
+ description=(
386
+ "Defaults to 'retained': F-56 authorises listing retained jobs. "
387
+ "Pass 'one_off' to list one-off jobs still inside their lifetime."
388
+ ),
389
+ ),
390
+ ) -> JobPage:
391
+ """F-56's list: summaries only, scoped to the caller's own jobs.
392
+
393
+ The body text is absent by design -- F-56 makes the summary the
394
+ default and puts the snapshot behind its own request -- and so is
395
+ every other owner's row, unless the credential is the GUI owner's,
396
+ which F-50 allows to review all jobs.
397
+ """
398
+ credential = require_capability(context, request, Capability.READ_HISTORY)
399
+ page = context.store.list_jobs(
400
+ owner_client_id=owner_scope(credential),
401
+ retention=retention or RetentionMode.RETAINED,
402
+ states=[state] if state is not None else None,
403
+ model_id=model_id,
404
+ limit=limit,
405
+ offset=max(0, offset),
406
+ include_source_text=False,
407
+ )
408
+ return JobPage(
409
+ items=[_summary_out(summary) for summary in page.items],
410
+ total=page.total,
411
+ limit=page.limit,
412
+ offset=page.offset,
413
+ has_more=page.has_more,
414
+ )
415
+
416
+ # ==================================================================
417
+ # F-54 -- one job's state
418
+ # ==================================================================
419
+
420
+ @router.get(
421
+ "/jobs/{job_id}",
422
+ response_model=JobOut,
423
+ summary="State, progress, and whether the result is ready",
424
+ )
425
+ def get_job(request: Request, job_id: str) -> JobOut:
426
+ """F-48's status query. A job owned by another client is 404 (N-19)."""
427
+ _, job = require_job(context, request, job_id, Capability.GENERATE, include_segments=True)
428
+ return _job_out(context, job)
429
+
430
+ # ==================================================================
431
+ # F-56 -- the source-text snapshot
432
+ # ==================================================================
433
+
434
+ @router.get(
435
+ "/jobs/{job_id}/text",
436
+ response_model=JobTextOut,
437
+ summary="Source-text snapshot of a retained job, separately authorised",
438
+ )
439
+ def get_job_text(request: Request, job_id: str) -> JobTextOut:
440
+ """F-56's separate, separately authorised snapshot request.
441
+
442
+ A job whose snapshot is gone answers 404 rather than an empty string.
443
+ 4.2 lets a one-off job's text be cleared once its window closes, and
444
+ reporting that as "the text is ''" would be a different fact.
445
+ """
446
+ _, job = require_job(context, request, job_id, Capability.READ_HISTORY)
447
+ text = context.store.get_job_text(job_id)
448
+ if text is None:
449
+ raise EchoActError(
450
+ Code.NOT_FOUND,
451
+ "This job no longer holds a source-text snapshot.",
452
+ detail={"job_id": job_id, "retention": job.retention.value},
453
+ )
454
+ return JobTextOut(
455
+ job_id=job_id, text=text, codepoints=len(text), retention=job.retention
456
+ )
457
+
458
+ # ==================================================================
459
+ # F-49 -- cancellation
460
+ # ==================================================================
461
+
462
+ @router.post(
463
+ "/jobs/{job_id}/cancel",
464
+ response_model=JobOut,
465
+ status_code=202,
466
+ summary="Cancel a job; an already-terminated job answers 200 with its final state",
467
+ responses={
468
+ 200: {
469
+ "model": JobOut,
470
+ "description": "The job had already reached a terminal state; it is returned.",
471
+ },
472
+ 202: {"description": "The cancellation was accepted; the slot is released shortly."},
473
+ },
474
+ )
475
+ def post_cancel(request: Request, response: Response, job_id: str) -> JobOut:
476
+ """F-49's cancellation, and N-22's two different deadlines.
477
+
478
+ Cancelling has to be *accepted* within a second at p95, while the
479
+ generation slot has five seconds to be released. The kill therefore
480
+ runs on the service's own thread pool and this handler answers as soon
481
+ as it knows the cancellation was taken, waiting only a quarter second
482
+ in case the job stops instantly and a truer state can be reported.
483
+
484
+ Repeating it adds no further side effects, which F-49 requires: an
485
+ already-terminal job is answered 200 with the state it reached, and
486
+ nothing is asked of the engine at all.
487
+ """
488
+ _, job = require_job(context, request, job_id, Capability.GENERATE)
489
+ if job.state.is_terminal:
490
+ response.status_code = 200
491
+ return _job_out(context, _reload(context, job_id))
492
+
493
+ future = context.executor.submit(context.engine.cancel, job_id)
494
+ try:
495
+ future.result(CANCEL_ACK_GRACE_S)
496
+ except TimeoutError:
497
+ # The expected case for a job that is mid-segment: the worker has
498
+ # up to five seconds to die and the caller is not made to wait for
499
+ # it. The kill goes on without this request.
500
+ pass
501
+ response.status_code = 202
502
+ return _job_out(context, _reload(context, job_id))
503
+
504
+ # ==================================================================
505
+ # F-55 -- segments
506
+ # ==================================================================
507
+
508
+ @router.get(
509
+ "/jobs/{job_id}/segments",
510
+ response_model=SegmentsOut,
511
+ summary="Ready segments and the last sequence number",
512
+ )
513
+ def get_segments(request: Request, job_id: str) -> SegmentsOut:
514
+ """F-55's mapping for the segments that exist.
515
+
516
+ Only ready segments are listed. F-55 forbids returning an ungenerated
517
+ segment as if it were a finished result, and the honest way to say
518
+ "there are more coming" is ``complete: false`` beside a count, not a
519
+ row with null times in it.
520
+ """
521
+ _, job = require_job(context, request, job_id, Capability.READ_RESULTS)
522
+ ready = context.store.list_segments(job_id, ready_only=True)
523
+ return SegmentsOut(
524
+ job_id=job_id,
525
+ segments=[_segment_out(segment) for segment in ready],
526
+ last_sequence=ready[-1].index if ready else None,
527
+ ready_count=len(ready),
528
+ total_segments=job.total_segments,
529
+ complete=job.state is JobState.COMPLETE,
530
+ )
531
+
532
+ @router.get(
533
+ "/jobs/{job_id}/segments/{segment_id}/audio",
534
+ summary="Audio of one ready segment",
535
+ response_class=StreamingResponse,
536
+ responses={200: {"content": {AUDIO_MEDIA_TYPE: {}}, "description": "The segment's WAV."}},
537
+ )
538
+ def get_segment_audio(request: Request, job_id: str, segment_id: str) -> StreamingResponse:
539
+ """One ready segment's WAV, streamed.
540
+
541
+ A segment that exists but carries no audio -- a range of characters
542
+ F-27 keeps on the timeline without sending it for synthesis -- is
543
+ reported as gone rather than as not yet ready. "Not ready" invites a
544
+ retry, and this one will never become ready. Neither will one whose
545
+ job has already ended, which is why the refusal below asks the job
546
+ and not only the segment.
547
+ """
548
+ _, job = require_job(context, request, job_id, Capability.READ_RESULTS)
549
+ segment = _find_segment(context, job_id, segment_id)
550
+ if not segment.ready:
551
+ raise _not_ready(
552
+ job,
553
+ Code.SEGMENT_NOT_READY,
554
+ {"job_id": job_id, "segment_id": segment_id, "index": segment.index},
555
+ ended="That segment was never generated; the job ended first.",
556
+ )
557
+ path = resolve_managed_audio(context, segment.audio_path)
558
+ if path is None:
559
+ raise EchoActError(
560
+ Code.RESULT_MISSING,
561
+ "That segment carries no audio of its own.",
562
+ detail={
563
+ "job_id": job_id,
564
+ "segment_id": segment_id,
565
+ "spoken": segment.is_spoken,
566
+ },
567
+ )
568
+ return _stream(path, f"{job_id}-{segment.index:05d}.wav")
569
+
570
+ # ==================================================================
571
+ # F-55 -- the full result
572
+ # ==================================================================
573
+
574
+ @router.get(
575
+ "/jobs/{job_id}/audio",
576
+ summary="Completed full audio",
577
+ response_class=StreamingResponse,
578
+ responses={200: {"content": {AUDIO_MEDIA_TYPE: {}}, "description": "The job's WAV."}},
579
+ )
580
+ def get_audio(request: Request, job_id: str) -> StreamingResponse:
581
+ """F-55's completed WAV, in F-82's format, streamed rather than loaded.
582
+
583
+ An incomplete job answers 409 and never a partial file: F-55 forbids
584
+ handing back an incomplete final file as though it were finished, and
585
+ F-16's partial export is a GUI operation the user performs knowingly,
586
+ not something a client gets by asking early.
587
+ """
588
+ _, job = require_job(context, request, job_id, Capability.READ_RESULTS)
589
+ result = _require_result(job)
590
+ if job.state is not JobState.COMPLETE:
591
+ raise _not_ready(
592
+ job,
593
+ Code.RESULT_NOT_READY,
594
+ {"job_id": job_id},
595
+ ended="This job ended before its full audio was assembled.",
596
+ )
597
+ if _expired(result):
598
+ raise EchoActError(
599
+ Code.RESULT_EXPIRED, detail={"job_id": job_id, "expires_at": result.expires_at}
600
+ )
601
+ path = resolve_managed_audio(context, result.relative_path)
602
+ if path is None:
603
+ raise EchoActError(Code.RESULT_MISSING, detail={"job_id": job_id})
604
+ return _stream(path, f"{job_id}.wav")
605
+
606
+ @router.get(
607
+ "/jobs/{job_id}/result",
608
+ response_model=ResultOut,
609
+ summary="Result metadata: format, rate, length, size, integrity value, expiry",
610
+ )
611
+ def get_result(request: Request, job_id: str) -> ResultOut:
612
+ """4.2's Result entity.
613
+
614
+ ``expired`` and ``available`` are separate answers. 4.2 keeps a
615
+ result row after its lifetime passes so that a repeated re-request key
616
+ can return the existing job and the result's expired state, and 5.3
617
+ wants the reason retrieval fails rather than a bare absence.
618
+ """
619
+ _, job = require_job(context, request, job_id, Capability.READ_RESULTS)
620
+ result = _require_result(job)
621
+ path = resolve_managed_audio(context, result.relative_path)
622
+ expired = _expired(result)
623
+ return ResultOut(
624
+ job_id=job_id,
625
+ result_id=result.result_id,
626
+ format=wav.FORMAT_NAME,
627
+ media_type=AUDIO_MEDIA_TYPE,
628
+ sample_rate=result.sample_rate,
629
+ channels=result.channels,
630
+ sample_width_bits=result.sample_width_bits,
631
+ frame_count=result.frame_count,
632
+ duration_ms=result.duration_ms,
633
+ byte_size=result.byte_size,
634
+ integrity=IntegrityOut(
635
+ algorithm="sha256",
636
+ value=result.digest,
637
+ state=context.store.result_integrity(result.result_id).value,
638
+ ),
639
+ created_at=result.created_at,
640
+ expires_at=result.expires_at,
641
+ expired=expired,
642
+ available=bool(path is not None and not expired),
643
+ )
644
+
645
+ return router
646
+
647
+
648
+ # ======================================================================
649
+ # Job creation, off the event loop
650
+ # ======================================================================
651
+
652
+
653
+ async def _read_create_request(
654
+ request: Request,
655
+ ) -> tuple[CreateJobRequest, bytes | None, str | None]:
656
+ """Read F-54's two alternative body shapes.
657
+
658
+ JSON carries the text inline; multipart carries the same object in a
659
+ ``request`` part beside the file. One model validates both, so the upload
660
+ path cannot drift into accepting something the inline path refuses -- F-37
661
+ requires every entry path to apply the same rules.
662
+ """
663
+ content_type = request.headers.get("content-type", "")
664
+ media_type = content_type.split(";", 1)[0].strip().lower()
665
+
666
+ if media_type == "application/json" or media_type == "":
667
+ raw = await request.body()
668
+ return _validate_create(raw or b"{}"), None, None
669
+
670
+ if media_type in ("multipart/form-data", "application/x-www-form-urlencoded"):
671
+ form = await _read_form(request)
672
+ try:
673
+ described = form.get("request")
674
+ upload = form.get("file")
675
+ if described is None or not isinstance(described, str):
676
+ raise EchoActError(
677
+ Code.JOB_KIND_MISSING,
678
+ "An upload must carry a 'request' part describing the job.",
679
+ detail={"field": "request"},
680
+ )
681
+ data: bytes | None = None
682
+ filename: str | None = None
683
+ if upload is not None and not isinstance(upload, str):
684
+ data = await upload.read()
685
+ filename = upload.filename
686
+ return _validate_create(described.encode("utf-8")), data, filename
687
+ finally:
688
+ await form.close()
689
+
690
+ raise EchoActError(
691
+ Code.FILE_UNSUPPORTED,
692
+ "The request body must be JSON or a multipart upload.",
693
+ detail={"content_type": media_type},
694
+ )
695
+
696
+
697
+ async def _read_form(request: Request) -> FormData:
698
+ """Parse a form body, with Section 2.10's status for one that will not parse.
699
+
700
+ Reading the form here rather than through a declared parameter is what
701
+ lets one model validate both body shapes, but it also steps around the
702
+ 400 FastAPI wraps its own form handling in. A body whose multipart
703
+ framing is broken, whose boundary is missing, or which carries more parts
704
+ than the parser will assemble raises out of the parser as neither an
705
+ ``EchoActError`` nor a validation error, so without this it reaches the
706
+ unexpected-exception handler: 500 INTERNAL, a status Section 2.10 does
707
+ not list for a malformed request, in an envelope carrying no code a
708
+ client can act on. Starlette's refusal takes two shapes -- it wraps the
709
+ parser's exception in a bare 400 when the request carries an app in its
710
+ scope and re-raises the exception itself otherwise -- and the bare 400 is
711
+ no better here: it reaches ``http_exception_error`` as a status with no
712
+ code behind it and becomes the same 500. Both are caught.
713
+
714
+ ``max_part_size`` is 4.1's request cap rather than Starlette's 1 MiB
715
+ default, so the only size limit on a body is the one the gate has already
716
+ enforced -- before a byte was read, from ``Content-Length``, or as the
717
+ chunks arrived. Left at the default, a part well inside 4.1's 2,000,000
718
+ bytes is refused by a limit no part of this contract states.
719
+ """
720
+ try:
721
+ return await request.form(max_part_size=MAX_REQUEST_BODY_BYTES)
722
+ except StarletteHTTPException as exc:
723
+ if exc.status_code != 400:
724
+ raise
725
+ raise _unreadable_form(exc) from exc
726
+ except (MultiPartException, FormParserError) as exc:
727
+ raise _unreadable_form(exc) from exc
728
+
729
+
730
+ def _unreadable_form(cause: Exception) -> EchoActError:
731
+ """A body that will not parse, as Section 2.10's 400 with a code on it.
732
+
733
+ The code is the same one an unparseable JSON body already answers with:
734
+ a body nothing could be read out of has, among other things, stated no
735
+ job kind. The parser's own text is dropped rather than passed on --
736
+ it quotes the boundary and the byte offset it stopped at, which is a
737
+ fragment of the caller's body, and F-57 keeps that out of a response
738
+ just as N-20 keeps it out of a log.
739
+ """
740
+ return EchoActError(
741
+ Code.MALFORMED_REQUEST,
742
+ "The request body could not be read as a form.",
743
+ detail={"field": "body"},
744
+ cause=cause,
745
+ )
746
+
747
+
748
+ def _validate_create(raw: bytes) -> CreateJobRequest:
749
+ try:
750
+ return CreateJobRequest.model_validate_json(raw)
751
+ except ValidationError as exc:
752
+ # Re-raised as FastAPI's own type so that the single handler in
753
+ # ``service.errors`` decides the code and status; a second translation
754
+ # here is a second place for the contract to drift.
755
+ raise RequestValidationError(exc.errors()) from exc
756
+
757
+
758
+ def _create_job(
759
+ context: ServiceContext,
760
+ request: Request,
761
+ response: Response,
762
+ body: CreateJobRequest,
763
+ upload: bytes | None,
764
+ filename: str | None,
765
+ ) -> JobOut:
766
+ """F-54 and F-49, on a worker thread.
767
+
768
+ The bounded wait is the last thing that happens and changes only when the
769
+ answer is sent. F-88 is explicit that waiting is an optimisation and
770
+ never a different lifecycle, so the job is identical whether the caller
771
+ waited, the bound passed, or the caller never asked.
772
+ """
773
+ credential = require_capability(context, request, Capability.GENERATE)
774
+ text = _resolve_input(body, upload, filename)
775
+ settings = _voice_settings(body.voice)
776
+
777
+ wait_s = clamp_wait(body.wait_s, ceiling_s=context.bounded_wait_ceiling_s)
778
+ # F-88: never wait while a model is being prepared or downloaded. Asked
779
+ # before submitting, because once the run thread starts the answer becomes
780
+ # a race with it rather than a property of this request.
781
+ would_load = context.engine.would_need_load(settings) if wait_s > 0 else False
782
+
783
+ job_request = JobRequest(
784
+ text=text,
785
+ settings=settings,
786
+ request_path=RequestPath.REST,
787
+ owner_client_id=credential.client_id,
788
+ idempotency_key=body.idempotency_key,
789
+ kind=body.kind,
790
+ retention=RetentionMode.RETAINED if body.retain else RetentionMode.ONE_OFF,
791
+ # F-70 names the client in a completion notice. The credential's own
792
+ # name is used rather than a label the caller supplies: N-18 keeps
793
+ # client-supplied text as data, and a self-chosen label in a
794
+ # notification is text the app would be repeating on trust.
795
+ client_label=credential.name,
796
+ wait_s=wait_s or None,
797
+ )
798
+ job, created = context.engine.submit(job_request)
799
+
800
+ waited_s = 0.0
801
+ if created and wait_s > 0 and not would_load:
802
+ started = ids.monotonic()
803
+ job = context.engine.wait(job.job_id, wait_s)
804
+ waited_s = round(ids.monotonic() - started, 3)
805
+
806
+ if not created:
807
+ # 4.2: the same key with the same content returns the existing job and
808
+ # its state, terminal or expired included, and regenerates nothing.
809
+ response.status_code = 200
810
+ elif job.state.is_terminal:
811
+ response.status_code = 200
812
+ else:
813
+ response.status_code = 202
814
+ return _job_out(context, job, waited_s=waited_s, duplicate=not created)
815
+
816
+
817
+ def _resolve_input(
818
+ body: CreateJobRequest, upload: bytes | None, filename: str | None
819
+ ) -> str:
820
+ """F-54: inline text or an upload, never both and never neither.
821
+
822
+ An upload goes through ``text.loader.load_bytes``, which is the same
823
+ function the file dialog uses. F-37 requires an automated caller to reach
824
+ the identical checks in the identical order, and sharing the function is
825
+ the only way that stays true as the checks change.
826
+ """
827
+ has_text = body.text is not None
828
+ has_upload = upload is not None
829
+ if has_text and has_upload:
830
+ raise EchoActError(Code.INPUT_AMBIGUOUS)
831
+ if has_upload:
832
+ loaded = load_bytes(
833
+ upload or b"",
834
+ filename=filename,
835
+ encoding=body.encoding,
836
+ confirm_unknown=body.confirm_unknown,
837
+ )
838
+ return loaded.text
839
+ if not has_text:
840
+ raise EchoActError(Code.INPUT_EMPTY, "Provide inline text or an upload.")
841
+ return body.text or ""
842
+
843
+
844
+ # ======================================================================
845
+ # Projections
846
+ # ======================================================================
847
+
848
+
849
+ def _voice_settings(voice: Any) -> VoiceSettings:
850
+ return VoiceSettings(
851
+ model_id=voice.model_id,
852
+ language=voice.language,
853
+ gender=voice.gender,
854
+ voice_id=voice.voice_id,
855
+ style=voice.style,
856
+ tempo=voice.tempo,
857
+ )
858
+
859
+
860
+ def _voice_out(settings: VoiceSettings) -> VoiceOut:
861
+ return VoiceOut(
862
+ model_id=settings.model_id,
863
+ voice_id=settings.voice_id,
864
+ gender=settings.gender,
865
+ language=settings.language,
866
+ style=settings.style,
867
+ tempo=settings.tempo,
868
+ )
869
+
870
+
871
+ def _budget_out(budget: Budget | None) -> BudgetOut | None:
872
+ if budget is None:
873
+ return None
874
+ return BudgetOut(
875
+ cpu_percent=budget.cpu_percent,
876
+ memory_bytes=budget.memory_bytes,
877
+ intra_op_threads=budget.intra_op_threads,
878
+ inter_op_threads=budget.inter_op_threads,
879
+ )
880
+
881
+
882
+ def _job_error(code: str | None, message: str | None) -> JobError | None:
883
+ """5.3: a generation failure after acceptance is the job's state, not the
884
+ request's, so it is reported here with the same three facts F-57 puts in a
885
+ refusal -- code, message, and whether a retry can succeed."""
886
+ if not code:
887
+ return None
888
+ try:
889
+ parsed = Code(code)
890
+ except ValueError:
891
+ return JobError(code=code, message=message, retryable=False)
892
+ return JobError(code=parsed.value, message=message, retryable=is_retryable(parsed))
893
+
894
+
895
+ def _job_out(
896
+ context: ServiceContext,
897
+ job: Job,
898
+ *,
899
+ waited_s: float | None = None,
900
+ duplicate: bool | None = None,
901
+ ) -> JobOut:
902
+ result = job.result
903
+ expired = _expired(result) if result is not None else False
904
+ duration_ms = result.duration_ms if result is not None else job.playable_ms()
905
+ return JobOut(
906
+ job_id=job.job_id,
907
+ kind=job.kind,
908
+ state=job.state,
909
+ terminal=job.state.is_terminal,
910
+ request_path=job.request_path,
911
+ owner_client_id=job.owner_client_id,
912
+ client_label=job.client_label,
913
+ retention=job.retention,
914
+ voice=_voice_out(job.settings),
915
+ budget=_budget_out(job.budget),
916
+ progress=JobProgress(
917
+ generated_segments=job.generated_segments,
918
+ total_segments=job.total_segments,
919
+ fraction=round(job.progress, 4),
920
+ ),
921
+ created_at=job.created_at,
922
+ started_at=job.started_at,
923
+ ended_at=job.ended_at,
924
+ result_ready=bool(result is not None and not expired),
925
+ result_expired=expired,
926
+ audio_duration_ms=duration_ms,
927
+ error=_job_error(job.error_code, job.error_message),
928
+ waited_s=waited_s,
929
+ duplicate=duplicate,
930
+ )
931
+
932
+
933
+ def _summary_out(summary: JobSummary) -> JobOut:
934
+ """The same shape from F-56's summary row, which never reads a WAV."""
935
+ return JobOut(
936
+ job_id=summary.job_id,
937
+ kind=summary.kind,
938
+ state=summary.state,
939
+ terminal=summary.state.is_terminal,
940
+ request_path=summary.request_path,
941
+ owner_client_id=summary.owner_client_id,
942
+ client_label=summary.client_label,
943
+ retention=summary.retention,
944
+ voice=_voice_out(summary.settings),
945
+ budget=_budget_out(summary.budget),
946
+ progress=JobProgress(
947
+ generated_segments=summary.generated_segments,
948
+ total_segments=summary.total_segments,
949
+ fraction=round(
950
+ min(1.0, summary.generated_segments / summary.total_segments)
951
+ if summary.total_segments > 0
952
+ else 0.0,
953
+ 4,
954
+ ),
955
+ ),
956
+ created_at=summary.created_at,
957
+ started_at=summary.started_at,
958
+ ended_at=summary.ended_at,
959
+ result_ready=bool(summary.has_result and not summary.result_expired),
960
+ result_expired=summary.result_expired,
961
+ audio_duration_ms=summary.audio_duration_ms,
962
+ error=_job_error(summary.error_code, summary.error_message),
963
+ )
964
+
965
+
966
+ def _segment_out(segment: Segment) -> SegmentOut:
967
+ span = segment.time
968
+ return SegmentOut(
969
+ segment_id=segment.segment_id,
970
+ index=segment.index,
971
+ source_start=segment.source.start,
972
+ source_end=segment.source.end,
973
+ start_ms=span.start_ms if span else 0,
974
+ end_ms=span.end_ms if span else 0,
975
+ duration_ms=span.duration_ms if span else 0,
976
+ language=segment.language,
977
+ spoken=segment.is_spoken,
978
+ has_audio=bool(segment.audio_path),
979
+ )
980
+
981
+
982
+ # ======================================================================
983
+ # Small helpers
984
+ # ======================================================================
985
+
986
+
987
+ def _app_version() -> str:
988
+ from .. import __version__
989
+
990
+ return __version__
991
+
992
+
993
+ def _current_budget(context: ServiceContext) -> tuple[Budget | None, str | None]:
994
+ """The budget in force, or why there is none.
995
+
996
+ F-23 refuses to load a model under the floor, and ``resolve_budget_from_system``
997
+ says so by raising. A status query must not fail for that reason -- it is
998
+ precisely the query that should report it -- so the refusal becomes a
999
+ reason string with no path or host detail in it.
1000
+ """
1001
+ try:
1002
+ return resolve_budget_from_system(context.settings), None
1003
+ except EchoActError as exc:
1004
+ return None, exc.message
1005
+
1006
+
1007
+ def _model_ready(context: ServiceContext, model_id: str) -> bool:
1008
+ """Shallow readiness, for F-88's estimate.
1009
+
1010
+ Shallow because N-22 gives an estimate one second at p95 and a deep verify
1011
+ hashes 385 MB. An unknown model is simply not ready; the estimate's own
1012
+ validation reports *why* it is unknown.
1013
+ """
1014
+ if not context.manifest.has(model_id):
1015
+ return False
1016
+ try:
1017
+ return context.registry.status(model_id, deep=False).state is ModelState.READY
1018
+ except EchoActError:
1019
+ return False
1020
+
1021
+
1022
+ def _reload(context: ServiceContext, job_id: str) -> Job:
1023
+ return context.store.get_job(job_id, include_source_text=False, include_segments=True)
1024
+
1025
+
1026
+ def _find_segment(context: ServiceContext, job_id: str, segment_id: str) -> Segment:
1027
+ for segment in context.store.list_segments(job_id):
1028
+ if segment.segment_id == segment_id:
1029
+ return segment
1030
+ raise EchoActError(
1031
+ Code.NOT_FOUND, "No such segment.", detail={"job_id": job_id, "segment_id": segment_id}
1032
+ )
1033
+
1034
+
1035
+ def _require_result(job: Job) -> Any:
1036
+ if job.result is None:
1037
+ raise _not_ready(
1038
+ job,
1039
+ Code.RESULT_NOT_READY,
1040
+ {"job_id": job.job_id},
1041
+ ended="This job ended without producing a result.",
1042
+ )
1043
+ return job.result
1044
+
1045
+
1046
+ def _not_ready(job: Job, code: Code, detail: dict[str, Any], *, ended: str) -> EchoActError:
1047
+ """"Not finished yet" and "this will never finish" are different refusals.
1048
+
1049
+ Whether something is still coming is a fact about the *job*, not about
1050
+ the row being asked for, so the state decides which of the two this is.
1051
+ While the job can still get there, the answer is the retryable code with
1052
+ N-23's hint on it. Once the job has ended it cannot, and rule 8 forbids
1053
+ a permanent refusal from carrying a hint at all: a client that honours
1054
+ the hint -- which F-47 says it must -- would otherwise poll a failed job
1055
+ every three seconds for a result that can never appear. What it gets
1056
+ instead is 5.3's reason, the job's terminal state and its own failure
1057
+ code, in the same envelope the no-audio segment case already answers.
1058
+ """
1059
+ described = {**detail, "state": job.state.value}
1060
+ if not job.state.is_terminal:
1061
+ return EchoActError(code, retry_after_s=NOT_READY_RETRY_AFTER_S, detail=described)
1062
+ if job.error_code:
1063
+ described["error_code"] = job.error_code
1064
+ return EchoActError(Code.RESULT_MISSING, ended, detail=described)
1065
+
1066
+
1067
+ def _expired(result: Any) -> bool:
1068
+ return result.expires_at is not None and result.expires_at <= ids.now()
1069
+
1070
+
1071
+ def _stream(path: Path, download_name: str) -> StreamingResponse:
1072
+ """Stream a managed WAV.
1073
+
1074
+ ``Content-Length`` comes from the file on disk rather than from the stored
1075
+ row: the row is what the digest was taken over, and answering with a
1076
+ length the body will not match is worse than answering with the truth.
1077
+ The filename offered is built from identifiers the app minted, so nothing
1078
+ a caller supplied ever reaches a header.
1079
+ """
1080
+ try:
1081
+ size = path.stat().st_size
1082
+ except OSError as exc:
1083
+ raise EchoActError(Code.RESULT_MISSING, cause=exc) from exc
1084
+ headers = {
1085
+ **_AUDIO_HEADERS,
1086
+ "Content-Length": str(size),
1087
+ "Content-Disposition": f'attachment; filename="{download_name}"',
1088
+ }
1089
+ return StreamingResponse(iter_file(path), media_type=AUDIO_MEDIA_TYPE, headers=headers)
1090
+
1091
+
1092
+ def _create_job_request_body() -> dict[str, Any]:
1093
+ """The two alternative bodies F-54 allows, for the OpenAPI document.
1094
+
1095
+ Written by hand because FastAPI describes one body per operation and this
1096
+ operation has two: inline JSON, or the same object in a ``request`` part
1097
+ beside an uploaded file. F-57 requires the specification to be accurate,
1098
+ and an omitted alternative is an inaccuracy a client discovers at runtime.
1099
+ """
1100
+ reference = {"$ref": "#/components/schemas/CreateJobRequest"}
1101
+ return {
1102
+ "required": True,
1103
+ "content": {
1104
+ "application/json": {"schema": reference},
1105
+ "multipart/form-data": {
1106
+ "schema": {
1107
+ "type": "object",
1108
+ "required": ["request"],
1109
+ "properties": {
1110
+ "request": {
1111
+ "type": "string",
1112
+ "description": (
1113
+ "The CreateJobRequest object, JSON-encoded, with 'text' omitted."
1114
+ ),
1115
+ },
1116
+ "file": {
1117
+ "type": "string",
1118
+ "format": "binary",
1119
+ "description": "A TXT or Markdown file to be validated and spoken.",
1120
+ },
1121
+ },
1122
+ }
1123
+ },
1124
+ },
1125
+ }