nnserve 0.0.1__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.
- nnserve/__init__.py +6 -0
- nnserve/api.py +389 -0
- nnserve/app.py +70 -0
- nnserve/asr/__init__.py +5 -0
- nnserve/asr/audio.py +41 -0
- nnserve/asr/format.py +50 -0
- nnserve/asr/whistle.py +29 -0
- nnserve/cli.py +27 -0
- nnserve/config.py +29 -0
- nnserve/documents.py +101 -0
- nnserve/handlers.py +75 -0
- nnserve/ocr/__init__.py +4 -0
- nnserve/ocr/geometry.py +215 -0
- nnserve/ocr/indra.py +158 -0
- nnserve/ocr/layout.py +340 -0
- nnserve/ocr/order.py +230 -0
- nnserve/ocr/pipeline.py +536 -0
- nnserve/registry.py +51 -0
- nnserve/schemas.py +294 -0
- nnserve/streaming.py +15 -0
- nnserve/tts/__init__.py +5 -0
- nnserve/tts/audio.py +57 -0
- nnserve/tts/kitten.py +44 -0
- nnserve/tts/qwen3.py +72 -0
- nnserve/version.py +1 -0
- nnserve-0.0.1.dist-info/METADATA +20 -0
- nnserve-0.0.1.dist-info/RECORD +29 -0
- nnserve-0.0.1.dist-info/WHEEL +4 -0
- nnserve-0.0.1.dist-info/entry_points.txt +2 -0
nnserve/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from nnserve.app import create_app
|
|
2
|
+
from nnserve.config import ModelConfig, ServeConfig
|
|
3
|
+
from nnserve.registry import ModelEntry, ModelRegistry
|
|
4
|
+
from nnserve.version import __version__
|
|
5
|
+
|
|
6
|
+
__all__ = ["ModelConfig", "ModelEntry", "ModelRegistry", "ServeConfig", "__version__", "create_app"]
|
nnserve/api.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# ruff: noqa: B008
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import APIRouter, File, Form, Request, Response, UploadFile
|
|
7
|
+
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
|
8
|
+
|
|
9
|
+
from nnserve.asr.audio import decode_audio
|
|
10
|
+
from nnserve.asr.format import render_transcription
|
|
11
|
+
from nnserve.documents import load_document, parse_page_selection, render_pages
|
|
12
|
+
from nnserve.handlers import ResponsesOutput, UsageStats
|
|
13
|
+
from nnserve.schemas import (
|
|
14
|
+
TRANSCRIPTION_FORMATS,
|
|
15
|
+
InputTokensDetails,
|
|
16
|
+
MessageItem,
|
|
17
|
+
ModelInfo,
|
|
18
|
+
ModelList,
|
|
19
|
+
OcrRequest,
|
|
20
|
+
OcrResponse,
|
|
21
|
+
OcrUsageInfo,
|
|
22
|
+
OutputText,
|
|
23
|
+
OutputTokensDetails,
|
|
24
|
+
ResponsesError,
|
|
25
|
+
ResponsesRequest,
|
|
26
|
+
ResponsesResponse,
|
|
27
|
+
ResponsesUsage,
|
|
28
|
+
SpeechRequest,
|
|
29
|
+
TextConfig,
|
|
30
|
+
TextFormat,
|
|
31
|
+
TranscriptionRequest,
|
|
32
|
+
)
|
|
33
|
+
from nnserve.streaming import EventSequence, encode_event
|
|
34
|
+
from nnserve.tts.audio import encode_audio
|
|
35
|
+
|
|
36
|
+
router = APIRouter()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ApiError(Exception):
|
|
40
|
+
def __init__(self, message, status_code=400, *, type="invalid_request_error", param=None, code=None):
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.message = message
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
self.type = type
|
|
45
|
+
self.param = param
|
|
46
|
+
self.code = code
|
|
47
|
+
|
|
48
|
+
def body(self):
|
|
49
|
+
return {"error": {"message": self.message, "type": self.type, "param": self.param, "code": self.code}}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def authorize(request: Request):
|
|
53
|
+
api_key = request.app.state.config.api_key
|
|
54
|
+
if api_key and request.headers.get("authorization") != f"Bearer {api_key}":
|
|
55
|
+
raise ApiError("Invalid API key", 401, code="invalid_api_key")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def model_entry(request: Request, model_id):
|
|
59
|
+
try:
|
|
60
|
+
return request.app.state.registry.get(model_id)
|
|
61
|
+
except KeyError:
|
|
62
|
+
raise ApiError(f"model {model_id!r} is not registered", 404, param="model", code="model_not_found") from None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def model_handler(request: Request, entry):
|
|
66
|
+
handler = request.app.state.handlers.get(entry.id)
|
|
67
|
+
if handler is None:
|
|
68
|
+
raise ApiError(f"model {entry.id!r} is not loaded", 503, type="server_error", code="model_not_loaded")
|
|
69
|
+
return handler
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def has_image(value):
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
return value.get("type") == "input_image" or any(has_image(item) for item in value.values())
|
|
75
|
+
if isinstance(value, list):
|
|
76
|
+
return any(has_image(item) for item in value)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_responses(request: ResponsesRequest, entry):
|
|
81
|
+
if request.tools:
|
|
82
|
+
raise ApiError("tools are not supported", param="tools", code="unsupported_parameter")
|
|
83
|
+
if request.tool_choice not in (None, "none"):
|
|
84
|
+
raise ApiError("tool_choice is not supported", param="tool_choice", code="unsupported_parameter")
|
|
85
|
+
format_type = request.text.format.type if request.text and request.text.format else "text"
|
|
86
|
+
if format_type in ("json_object", "json_schema"):
|
|
87
|
+
raise ApiError("structured text output is not supported", param="text.format", code="unsupported_parameter")
|
|
88
|
+
if not request.input and not request.instructions:
|
|
89
|
+
raise ApiError("input or instructions is required", param="input", code="missing_required_parameter")
|
|
90
|
+
if has_image(request.input) and "vision" not in entry.capabilities:
|
|
91
|
+
raise ApiError(f"model {entry.id!r} has no vision capability", param="input", code="unsupported_capability")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def text_config(request: ResponsesRequest):
|
|
95
|
+
if request.text is None:
|
|
96
|
+
return TextConfig(format=TextFormat())
|
|
97
|
+
if request.text.format is None:
|
|
98
|
+
return TextConfig(format=TextFormat(), verbosity=request.text.verbosity)
|
|
99
|
+
return request.text
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_usage(stats: UsageStats):
|
|
103
|
+
return ResponsesUsage(
|
|
104
|
+
input_tokens=stats.input_tokens,
|
|
105
|
+
input_tokens_details=InputTokensDetails(cached_tokens=stats.cached_tokens),
|
|
106
|
+
output_tokens=stats.output_tokens,
|
|
107
|
+
output_tokens_details=OutputTokensDetails(reasoning_tokens=stats.reasoning_tokens),
|
|
108
|
+
total_tokens=stats.input_tokens + stats.output_tokens,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_response(
|
|
113
|
+
request: ResponsesRequest,
|
|
114
|
+
output: ResponsesOutput,
|
|
115
|
+
*,
|
|
116
|
+
response_id=None,
|
|
117
|
+
item_id=None,
|
|
118
|
+
created_at=None,
|
|
119
|
+
status="completed",
|
|
120
|
+
error=None,
|
|
121
|
+
):
|
|
122
|
+
now = created_at or int(time.time())
|
|
123
|
+
item = MessageItem(
|
|
124
|
+
id=item_id or f"msg_{uuid.uuid4().hex}",
|
|
125
|
+
status="in_progress" if status == "in_progress" else "completed",
|
|
126
|
+
content=[OutputText(text=output.text)],
|
|
127
|
+
)
|
|
128
|
+
return ResponsesResponse(
|
|
129
|
+
id=response_id or f"resp_{uuid.uuid4().hex}",
|
|
130
|
+
created_at=now,
|
|
131
|
+
completed_at=None if status == "in_progress" else now,
|
|
132
|
+
status=status,
|
|
133
|
+
error=error,
|
|
134
|
+
model=request.model,
|
|
135
|
+
instructions=request.instructions,
|
|
136
|
+
output=[item],
|
|
137
|
+
output_text=output.text,
|
|
138
|
+
text=text_config(request),
|
|
139
|
+
temperature=request.temperature if request.temperature is not None else 1.0,
|
|
140
|
+
top_p=request.top_p if request.top_p is not None else 1.0,
|
|
141
|
+
truncation=request.truncation or "disabled",
|
|
142
|
+
parallel_tool_calls=request.parallel_tool_calls if request.parallel_tool_calls is not None else True,
|
|
143
|
+
usage=build_usage(output.usage),
|
|
144
|
+
max_output_tokens=request.max_output_tokens,
|
|
145
|
+
user=request.user,
|
|
146
|
+
metadata=request.metadata or {},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.get("/health")
|
|
151
|
+
def health():
|
|
152
|
+
return {"status": "ok"}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.get("/metrics")
|
|
156
|
+
def metrics(request: Request):
|
|
157
|
+
entries = request.app.state.registry.list()
|
|
158
|
+
lines = [
|
|
159
|
+
"# HELP nnserve_models Registered models",
|
|
160
|
+
"# TYPE nnserve_models gauge",
|
|
161
|
+
f"nnserve_models {len(entries)}",
|
|
162
|
+
]
|
|
163
|
+
return PlainTextResponse("\n".join(lines) + "\n")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.get("/v1/models")
|
|
167
|
+
def list_models(request: Request):
|
|
168
|
+
authorize(request)
|
|
169
|
+
entries = request.app.state.registry.list()
|
|
170
|
+
return ModelList(data=[ModelInfo(id=entry.id, task=entry.task, capabilities=list(entry.capabilities)) for entry in entries])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@router.get("/v1/models/{model_id}")
|
|
174
|
+
def get_model(model_id: str, request: Request):
|
|
175
|
+
authorize(request)
|
|
176
|
+
entry = model_entry(request, model_id)
|
|
177
|
+
return ModelInfo(id=entry.id, task=entry.task, capabilities=list(entry.capabilities))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@router.post("/v1/responses")
|
|
181
|
+
def create_response(payload: ResponsesRequest, request: Request):
|
|
182
|
+
authorize(request)
|
|
183
|
+
entry = model_entry(request, payload.model)
|
|
184
|
+
if entry.task not in ("chat", "vlm"):
|
|
185
|
+
raise ApiError(
|
|
186
|
+
f"model {entry.id!r} has task {entry.task!r}; use a task-specific endpoint",
|
|
187
|
+
param="model",
|
|
188
|
+
code="model_not_supported",
|
|
189
|
+
)
|
|
190
|
+
validate_responses(payload, entry)
|
|
191
|
+
handler = model_handler(request, entry)
|
|
192
|
+
|
|
193
|
+
if not payload.stream:
|
|
194
|
+
return build_response(payload, handler.respond(payload, entry))
|
|
195
|
+
|
|
196
|
+
response_id = f"resp_{uuid.uuid4().hex}"
|
|
197
|
+
item_id = f"msg_{uuid.uuid4().hex}"
|
|
198
|
+
created_at = int(time.time())
|
|
199
|
+
return StreamingResponse(
|
|
200
|
+
_response_stream(payload, entry, handler, response_id, item_id, created_at),
|
|
201
|
+
media_type="text/event-stream",
|
|
202
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _response_stream(payload, entry, handler, response_id, item_id, created_at):
|
|
207
|
+
sequence = EventSequence()
|
|
208
|
+
partial = build_response(
|
|
209
|
+
payload, ResponsesOutput(""), response_id=response_id, item_id=item_id, created_at=created_at, status="in_progress"
|
|
210
|
+
)
|
|
211
|
+
yield encode_event(sequence.next("response.created", response=partial.model_dump(mode="json")))
|
|
212
|
+
yield encode_event(sequence.next("response.in_progress", response=partial.model_dump(mode="json")))
|
|
213
|
+
yield encode_event(
|
|
214
|
+
sequence.next(
|
|
215
|
+
"response.output_item.added",
|
|
216
|
+
output_index=0,
|
|
217
|
+
item={"type": "message", "id": item_id, "status": "in_progress", "role": "assistant", "content": []},
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
yield encode_event(
|
|
221
|
+
sequence.next(
|
|
222
|
+
"response.content_part.added",
|
|
223
|
+
output_index=0,
|
|
224
|
+
content_index=0,
|
|
225
|
+
item_id=item_id,
|
|
226
|
+
part={"type": "output_text", "text": "", "annotations": [], "logprobs": []},
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
text = []
|
|
231
|
+
usage = UsageStats()
|
|
232
|
+
try:
|
|
233
|
+
for chunk in handler.stream(payload, entry):
|
|
234
|
+
if chunk.text:
|
|
235
|
+
text.append(chunk.text)
|
|
236
|
+
yield encode_event(
|
|
237
|
+
sequence.next(
|
|
238
|
+
"response.output_text.delta",
|
|
239
|
+
output_index=0,
|
|
240
|
+
content_index=0,
|
|
241
|
+
item_id=item_id,
|
|
242
|
+
delta=chunk.text,
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
if chunk.usage is not None:
|
|
246
|
+
usage = chunk.usage
|
|
247
|
+
except Exception as exc: # noqa: BLE001
|
|
248
|
+
failed = build_response(
|
|
249
|
+
payload,
|
|
250
|
+
ResponsesOutput("".join(text), usage),
|
|
251
|
+
response_id=response_id,
|
|
252
|
+
item_id=item_id,
|
|
253
|
+
created_at=created_at,
|
|
254
|
+
status="failed",
|
|
255
|
+
error=ResponsesError(code="server_error", message=str(exc), type="server_error"),
|
|
256
|
+
)
|
|
257
|
+
yield encode_event(sequence.next("response.failed", response=failed.model_dump(mode="json")))
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
output = "".join(text)
|
|
261
|
+
yield encode_event(
|
|
262
|
+
sequence.next("response.output_text.done", output_index=0, content_index=0, item_id=item_id, text=output)
|
|
263
|
+
)
|
|
264
|
+
yield encode_event(
|
|
265
|
+
sequence.next(
|
|
266
|
+
"response.content_part.done",
|
|
267
|
+
output_index=0,
|
|
268
|
+
content_index=0,
|
|
269
|
+
item_id=item_id,
|
|
270
|
+
part={"type": "output_text", "text": output, "annotations": [], "logprobs": []},
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
completed = build_response(
|
|
274
|
+
payload, ResponsesOutput(output, usage), response_id=response_id, item_id=item_id, created_at=created_at
|
|
275
|
+
)
|
|
276
|
+
yield encode_event(
|
|
277
|
+
sequence.next(
|
|
278
|
+
"response.output_item.done",
|
|
279
|
+
output_index=0,
|
|
280
|
+
item=completed.output[0].model_dump(mode="json"),
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
yield encode_event(sequence.next("response.completed", response=completed.model_dump(mode="json")))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@router.post("/v1/ocr")
|
|
287
|
+
def create_ocr(payload: OcrRequest, request: Request):
|
|
288
|
+
authorize(request)
|
|
289
|
+
entry = model_entry(request, payload.model)
|
|
290
|
+
if entry.task != "ocr":
|
|
291
|
+
raise ApiError(f"model {entry.id!r} has task {entry.task!r}", param="model", code="model_not_supported")
|
|
292
|
+
if payload.bbox_annotation_format or payload.document_annotation_format or payload.document_annotation_prompt:
|
|
293
|
+
raise ApiError("annotation formats are not supported", code="unsupported_parameter")
|
|
294
|
+
handler = model_handler(request, entry)
|
|
295
|
+
|
|
296
|
+
config = request.app.state.config
|
|
297
|
+
try:
|
|
298
|
+
source = load_document(payload.document, timeout=config.request_timeout)
|
|
299
|
+
selection = parse_page_selection(payload.pages)
|
|
300
|
+
pages = render_pages(source, selection, dpi=config.pdf_dpi)
|
|
301
|
+
except ValueError as exc:
|
|
302
|
+
raise ApiError(str(exc), code="invalid_document") from exc
|
|
303
|
+
except httpx.HTTPError as exc:
|
|
304
|
+
raise ApiError(f"could not fetch the document: {exc}", code="invalid_document") from exc
|
|
305
|
+
|
|
306
|
+
if not pages:
|
|
307
|
+
raise ApiError("no pages to process", code="invalid_document")
|
|
308
|
+
|
|
309
|
+
result = handler.process(payload, entry, pages)
|
|
310
|
+
return OcrResponse(
|
|
311
|
+
pages=result.pages,
|
|
312
|
+
model=payload.model,
|
|
313
|
+
document_annotation=result.document_annotation,
|
|
314
|
+
usage_info=OcrUsageInfo(pages_processed=len(result.pages), doc_size_bytes=source.size_bytes),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@router.post("/v1/audio/speech")
|
|
319
|
+
def create_speech(payload: SpeechRequest, request: Request):
|
|
320
|
+
authorize(request)
|
|
321
|
+
entry = model_entry(request, payload.model)
|
|
322
|
+
if entry.task != "tts":
|
|
323
|
+
raise ApiError(f"model {entry.id!r} has task {entry.task!r}", param="model", code="model_not_supported")
|
|
324
|
+
if payload.stream_format == "sse":
|
|
325
|
+
raise ApiError("stream_format sse is not supported", param="stream_format", code="unsupported_parameter")
|
|
326
|
+
handler = model_handler(request, entry)
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
audio = handler.synthesize(payload, entry)
|
|
330
|
+
except KeyError as exc:
|
|
331
|
+
raise ApiError(str(exc), param="voice", code="voice_not_found") from exc
|
|
332
|
+
except ValueError as exc:
|
|
333
|
+
raise ApiError(str(exc), code="invalid_request") from exc
|
|
334
|
+
|
|
335
|
+
content, media_type = encode_audio(audio.samples, audio.sample_rate, payload.response_format)
|
|
336
|
+
return Response(content=content, media_type=media_type)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@router.post("/v1/audio/transcriptions")
|
|
340
|
+
async def create_transcription(
|
|
341
|
+
request: Request,
|
|
342
|
+
file: UploadFile = File(...),
|
|
343
|
+
model: str = Form(...),
|
|
344
|
+
language: str | None = Form(None),
|
|
345
|
+
prompt: str | None = Form(None),
|
|
346
|
+
response_format: str = Form("json"),
|
|
347
|
+
temperature: float | None = Form(None),
|
|
348
|
+
timestamp_granularities: list[str] | None = Form(None, alias="timestamp_granularities[]"),
|
|
349
|
+
granularity_alias: list[str] | None = Form(None, alias="timestamp_granularities"),
|
|
350
|
+
):
|
|
351
|
+
authorize(request)
|
|
352
|
+
entry = model_entry(request, model)
|
|
353
|
+
if entry.task != "asr":
|
|
354
|
+
raise ApiError(f"model {entry.id!r} has task {entry.task!r}", param="model", code="model_not_supported")
|
|
355
|
+
if response_format not in TRANSCRIPTION_FORMATS:
|
|
356
|
+
raise ApiError(
|
|
357
|
+
f"response_format must be one of {TRANSCRIPTION_FORMATS}",
|
|
358
|
+
param="response_format",
|
|
359
|
+
code="invalid_request",
|
|
360
|
+
)
|
|
361
|
+
granularities = timestamp_granularities or granularity_alias or []
|
|
362
|
+
if any(value not in ("word", "segment") for value in granularities):
|
|
363
|
+
raise ApiError("timestamp_granularities must be word or segment", code="invalid_request")
|
|
364
|
+
handler = model_handler(request, entry)
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
audio = decode_audio(await file.read())
|
|
368
|
+
except ValueError as exc:
|
|
369
|
+
raise ApiError(str(exc), code="invalid_audio") from exc
|
|
370
|
+
except RuntimeError as exc:
|
|
371
|
+
raise ApiError(str(exc), status_code=500, type="server_error", code="audio_decode_unavailable") from exc
|
|
372
|
+
|
|
373
|
+
payload = TranscriptionRequest(
|
|
374
|
+
model=model,
|
|
375
|
+
language=language,
|
|
376
|
+
prompt=prompt,
|
|
377
|
+
response_format=response_format,
|
|
378
|
+
temperature=temperature,
|
|
379
|
+
timestamp_granularities=granularities,
|
|
380
|
+
)
|
|
381
|
+
try:
|
|
382
|
+
result = handler.transcribe(payload, entry, audio)
|
|
383
|
+
except ValueError as exc:
|
|
384
|
+
raise ApiError(str(exc), code="invalid_request") from exc
|
|
385
|
+
|
|
386
|
+
body, media_type = render_transcription(result, payload, len(audio) / 16000)
|
|
387
|
+
if isinstance(body, dict):
|
|
388
|
+
return JSONResponse(content=body)
|
|
389
|
+
return PlainTextResponse(content=body, media_type=media_type)
|
nnserve/app.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI, Request
|
|
5
|
+
from fastapi.exceptions import RequestValidationError
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
|
|
8
|
+
from nnserve.api import ApiError, router
|
|
9
|
+
from nnserve.config import ServeConfig
|
|
10
|
+
from nnserve.registry import ModelRegistry
|
|
11
|
+
from nnserve.version import __version__
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("nnserve")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def api_error_handler(request: Request, exc: ApiError):
|
|
17
|
+
return JSONResponse(status_code=exc.status_code, content=exc.body())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
|
21
|
+
errors = exc.errors()
|
|
22
|
+
first = errors[0] if errors else {}
|
|
23
|
+
location = [str(part) for part in first.get("loc", ())]
|
|
24
|
+
return JSONResponse(
|
|
25
|
+
status_code=400,
|
|
26
|
+
content={
|
|
27
|
+
"error": {
|
|
28
|
+
"message": str(first.get("msg", "Invalid request")),
|
|
29
|
+
"type": "invalid_request_error",
|
|
30
|
+
"param": location[-1] if location else None,
|
|
31
|
+
"code": "invalid_request",
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def unexpected_error_handler(request: Request, exc: Exception):
|
|
38
|
+
logger.exception("unhandled service error")
|
|
39
|
+
return JSONResponse(
|
|
40
|
+
status_code=500,
|
|
41
|
+
content={"error": {"message": str(exc), "type": "server_error", "param": None, "code": "server_error"}},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_handler(spec, entry):
|
|
46
|
+
module_name, separator, class_name = spec.partition(":")
|
|
47
|
+
if not separator or not module_name or not class_name:
|
|
48
|
+
raise ValueError(f'handler must be "module:ClassName", got {spec!r}')
|
|
49
|
+
return getattr(importlib.import_module(module_name), class_name)(entry)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_handlers(config, registry):
|
|
53
|
+
handlers = {}
|
|
54
|
+
for model in config.models:
|
|
55
|
+
if model.handler:
|
|
56
|
+
handlers[model.id] = load_handler(model.handler, registry.get(model.id))
|
|
57
|
+
return handlers
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def create_app(config: ServeConfig | None = None, handlers=None) -> FastAPI:
|
|
61
|
+
config = config or ServeConfig()
|
|
62
|
+
app = FastAPI(title="nnserve", version=__version__)
|
|
63
|
+
app.state.config = config
|
|
64
|
+
app.state.registry = ModelRegistry.from_models(config.models)
|
|
65
|
+
app.state.handlers = dict(handlers) if handlers is not None else build_handlers(config, app.state.registry)
|
|
66
|
+
app.include_router(router)
|
|
67
|
+
app.add_exception_handler(ApiError, api_error_handler)
|
|
68
|
+
app.add_exception_handler(RequestValidationError, validation_error_handler)
|
|
69
|
+
app.add_exception_handler(Exception, unexpected_error_handler)
|
|
70
|
+
return app
|
nnserve/asr/__init__.py
ADDED
nnserve/asr/audio.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
TARGET_RATE = 16000
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def decode_audio(data, sample_rate=TARGET_RATE):
|
|
10
|
+
if not data:
|
|
11
|
+
raise ValueError("empty audio upload")
|
|
12
|
+
if shutil.which("ffmpeg") is None:
|
|
13
|
+
raise RuntimeError("audio decoding requires ffmpeg")
|
|
14
|
+
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
[
|
|
17
|
+
"ffmpeg",
|
|
18
|
+
"-hide_banner",
|
|
19
|
+
"-loglevel",
|
|
20
|
+
"error",
|
|
21
|
+
"-i",
|
|
22
|
+
"pipe:0",
|
|
23
|
+
"-vn",
|
|
24
|
+
"-f",
|
|
25
|
+
"f32le",
|
|
26
|
+
"-ac",
|
|
27
|
+
"1",
|
|
28
|
+
"-ar",
|
|
29
|
+
str(sample_rate),
|
|
30
|
+
"pipe:1",
|
|
31
|
+
],
|
|
32
|
+
input=data,
|
|
33
|
+
capture_output=True,
|
|
34
|
+
check=False,
|
|
35
|
+
)
|
|
36
|
+
if result.returncode != 0:
|
|
37
|
+
raise ValueError(result.stderr.decode("utf-8", "ignore").strip() or "could not decode audio")
|
|
38
|
+
samples = np.frombuffer(result.stdout, dtype="<f4").copy()
|
|
39
|
+
if samples.size == 0:
|
|
40
|
+
raise ValueError("no audio samples decoded")
|
|
41
|
+
return samples
|
nnserve/asr/format.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
def timestamp(seconds, decimal=False):
|
|
2
|
+
milliseconds = max(0, round(seconds * 1000))
|
|
3
|
+
hours, remainder = divmod(milliseconds, 3600 * 1000)
|
|
4
|
+
minutes, remainder = divmod(remainder, 60 * 1000)
|
|
5
|
+
seconds_part, milliseconds = divmod(remainder, 1000)
|
|
6
|
+
separator = "." if decimal else ","
|
|
7
|
+
return f"{hours:02d}:{minutes:02d}:{seconds_part:02d}{separator}{milliseconds:03d}"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_transcription(result, request, duration):
|
|
11
|
+
response_format = request.response_format
|
|
12
|
+
if response_format == "text":
|
|
13
|
+
return result.text, "text/plain"
|
|
14
|
+
if response_format == "json":
|
|
15
|
+
return {"text": result.text}, "application/json"
|
|
16
|
+
|
|
17
|
+
if response_format == "verbose_json":
|
|
18
|
+
segments = (
|
|
19
|
+
[
|
|
20
|
+
{
|
|
21
|
+
"id": 0,
|
|
22
|
+
"seek": 0,
|
|
23
|
+
"start": 0.0,
|
|
24
|
+
"end": round(duration, 2),
|
|
25
|
+
"text": result.text,
|
|
26
|
+
"tokens": [],
|
|
27
|
+
"temperature": 0.0,
|
|
28
|
+
"avg_logprob": 0.0,
|
|
29
|
+
"compression_ratio": 0.0,
|
|
30
|
+
"no_speech_prob": 0.0,
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
if result.text
|
|
34
|
+
else []
|
|
35
|
+
)
|
|
36
|
+
body = {
|
|
37
|
+
"task": "transcribe",
|
|
38
|
+
"language": result.language,
|
|
39
|
+
"duration": round(duration, 2),
|
|
40
|
+
"text": result.text,
|
|
41
|
+
"segments": segments,
|
|
42
|
+
}
|
|
43
|
+
if "word" in request.timestamp_granularities:
|
|
44
|
+
body["words"] = [{"word": word["word"], "start": word["start"], "end": word["end"]} for word in result.words]
|
|
45
|
+
return body, "application/json"
|
|
46
|
+
|
|
47
|
+
cue = f"1\n{timestamp(0)} --> {timestamp(duration)}\n{result.text}\n"
|
|
48
|
+
if response_format == "srt":
|
|
49
|
+
return cue, "text/plain"
|
|
50
|
+
return f"WEBVTT\n\n{timestamp(0, decimal=True)} --> {timestamp(duration, decimal=True)}\n{result.text}\n", "text/plain"
|
nnserve/asr/whistle.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
from nnm.models.whistle.model import LANGUAGES, Whistle
|
|
5
|
+
|
|
6
|
+
from nnserve.handlers import AsrResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WhistleAsrHandler:
|
|
10
|
+
def __init__(self, entry):
|
|
11
|
+
self.model = Whistle.from_pretrained(entry.path)
|
|
12
|
+
self.lock = threading.Lock()
|
|
13
|
+
|
|
14
|
+
def transcribe(self, request, entry, audio):
|
|
15
|
+
if request.language and request.language not in LANGUAGES:
|
|
16
|
+
raise ValueError(f"language {request.language!r} is not supported")
|
|
17
|
+
word_timestamps = "word" in request.timestamp_granularities
|
|
18
|
+
with self.lock:
|
|
19
|
+
result = self.model.transcribe(
|
|
20
|
+
torch.from_numpy(audio),
|
|
21
|
+
language=request.language,
|
|
22
|
+
word_timestamps=word_timestamps,
|
|
23
|
+
beam=5,
|
|
24
|
+
)
|
|
25
|
+
return AsrResult(
|
|
26
|
+
text=result["text"],
|
|
27
|
+
language=result["language"],
|
|
28
|
+
words=result.get("words", []),
|
|
29
|
+
)
|
nnserve/cli.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
|
|
5
|
+
from nnserve.app import create_app
|
|
6
|
+
from nnserve.config import ServeConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv=None):
|
|
10
|
+
parser = argparse.ArgumentParser(prog="nnserve")
|
|
11
|
+
parser.add_argument("--config", required=True, help="path to the serve config JSON")
|
|
12
|
+
parser.add_argument("--host")
|
|
13
|
+
parser.add_argument("--port", type=int)
|
|
14
|
+
args = parser.parse_args(argv)
|
|
15
|
+
|
|
16
|
+
config = ServeConfig.from_json(args.config)
|
|
17
|
+
if args.host:
|
|
18
|
+
config.host = args.host
|
|
19
|
+
if args.port:
|
|
20
|
+
config.port = args.port
|
|
21
|
+
|
|
22
|
+
app = create_app(config)
|
|
23
|
+
uvicorn.run(app, host=config.host, port=config.port)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|
nnserve/config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ModelConfig:
|
|
8
|
+
id: str
|
|
9
|
+
path: str
|
|
10
|
+
task: str | None = None
|
|
11
|
+
capabilities: list[str] = field(default_factory=list)
|
|
12
|
+
handler: str | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ServeConfig:
|
|
17
|
+
models: list[ModelConfig] = field(default_factory=list)
|
|
18
|
+
host: str = "127.0.0.1"
|
|
19
|
+
port: int = 8000
|
|
20
|
+
api_key: str | None = None
|
|
21
|
+
pdf_dpi: float = 200.0
|
|
22
|
+
request_timeout: float = 30.0
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_json(cls, path):
|
|
26
|
+
data = json.loads(Path(path).read_text())
|
|
27
|
+
models = [ModelConfig(**item) for item in data.get("models", [])]
|
|
28
|
+
fields = {key: value for key, value in data.items() if key != "models"}
|
|
29
|
+
return cls(models=models, **fields)
|