nnserve 0.0.1__tar.gz
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-0.0.1/.githooks/pre-commit +13 -0
- nnserve-0.0.1/.github/FUNDING.yml +1 -0
- nnserve-0.0.1/.github/workflows/main.yml +51 -0
- nnserve-0.0.1/.gitignore +14 -0
- nnserve-0.0.1/PKG-INFO +20 -0
- nnserve-0.0.1/README.md +42 -0
- nnserve-0.0.1/nnserve/__init__.py +6 -0
- nnserve-0.0.1/nnserve/api.py +389 -0
- nnserve-0.0.1/nnserve/app.py +70 -0
- nnserve-0.0.1/nnserve/asr/__init__.py +5 -0
- nnserve-0.0.1/nnserve/asr/audio.py +41 -0
- nnserve-0.0.1/nnserve/asr/format.py +50 -0
- nnserve-0.0.1/nnserve/asr/whistle.py +29 -0
- nnserve-0.0.1/nnserve/cli.py +27 -0
- nnserve-0.0.1/nnserve/config.py +29 -0
- nnserve-0.0.1/nnserve/documents.py +101 -0
- nnserve-0.0.1/nnserve/handlers.py +75 -0
- nnserve-0.0.1/nnserve/ocr/__init__.py +4 -0
- nnserve-0.0.1/nnserve/ocr/geometry.py +215 -0
- nnserve-0.0.1/nnserve/ocr/indra.py +158 -0
- nnserve-0.0.1/nnserve/ocr/layout.py +340 -0
- nnserve-0.0.1/nnserve/ocr/order.py +230 -0
- nnserve-0.0.1/nnserve/ocr/pipeline.py +536 -0
- nnserve-0.0.1/nnserve/registry.py +51 -0
- nnserve-0.0.1/nnserve/schemas.py +294 -0
- nnserve-0.0.1/nnserve/streaming.py +15 -0
- nnserve-0.0.1/nnserve/tts/__init__.py +5 -0
- nnserve-0.0.1/nnserve/tts/audio.py +57 -0
- nnserve-0.0.1/nnserve/tts/kitten.py +44 -0
- nnserve-0.0.1/nnserve/tts/qwen3.py +72 -0
- nnserve-0.0.1/nnserve/version.py +1 -0
- nnserve-0.0.1/pyproject.toml +52 -0
- nnserve-0.0.1/tests/__init__.py +0 -0
- nnserve-0.0.1/tests/conftest.py +146 -0
- nnserve-0.0.1/tests/handlers.py +3 -0
- nnserve-0.0.1/tests/helpers.py +31 -0
- nnserve-0.0.1/tests/test_api.py +342 -0
- nnserve-0.0.1/tests/test_asr.py +81 -0
- nnserve-0.0.1/tests/test_documents.py +42 -0
- nnserve-0.0.1/tests/test_ocr_pipeline.py +225 -0
- nnserve-0.0.1/tests/test_registry.py +56 -0
- nnserve-0.0.1/tests/test_tts.py +91 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Auto-format staged python files; re-stage anything the formatter changed.
|
|
3
|
+
files=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.py$')
|
|
4
|
+
[ -z "$files" ] && exit 0
|
|
5
|
+
ruff format $files >/dev/null 2>&1 || {
|
|
6
|
+
echo "pre-commit: ruff format failed; is ruff on PATH?" >&2
|
|
7
|
+
exit 1
|
|
8
|
+
}
|
|
9
|
+
changed=$(git diff --name-only -- $files)
|
|
10
|
+
if [ -n "$changed" ]; then
|
|
11
|
+
git add $changed
|
|
12
|
+
echo "pre-commit: reformatted and re-staged: $changed"
|
|
13
|
+
fi
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
github: [JiauZhang]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Publish Python 🐍 distributions 📦 to PyPI
|
|
2
|
+
|
|
3
|
+
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
# Sequence of patterns matched against refs/heads
|
|
7
|
+
branches:
|
|
8
|
+
- main
|
|
9
|
+
# Sequence of patterns matched against refs/tags
|
|
10
|
+
tags:
|
|
11
|
+
- v*
|
|
12
|
+
paths-ignore:
|
|
13
|
+
- '**.md'
|
|
14
|
+
- 'docs/**'
|
|
15
|
+
- '**/.gitignore'
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
build-n-publish:
|
|
19
|
+
name: Build and publish Python 🐍 distributions 📦 to PyPI
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
# Specifying a GitHub environment is optional, but strongly encouraged
|
|
22
|
+
environment: nnserve_pypi_api
|
|
23
|
+
permissions:
|
|
24
|
+
# IMPORTANT: this permission is mandatory for Trusted Publishing
|
|
25
|
+
id-token: write
|
|
26
|
+
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v3
|
|
29
|
+
- name: Set up Python
|
|
30
|
+
uses: actions/setup-python@v4
|
|
31
|
+
with:
|
|
32
|
+
python-version: "3.11"
|
|
33
|
+
|
|
34
|
+
- name: Install pypa/build
|
|
35
|
+
run: >-
|
|
36
|
+
python -m
|
|
37
|
+
pip install
|
|
38
|
+
build
|
|
39
|
+
--user
|
|
40
|
+
- name: Build a binary wheel and a source tarball
|
|
41
|
+
run: >-
|
|
42
|
+
python -m
|
|
43
|
+
build
|
|
44
|
+
--sdist
|
|
45
|
+
--wheel
|
|
46
|
+
--outdir dist/
|
|
47
|
+
.
|
|
48
|
+
|
|
49
|
+
- name: Publish distribution 📦 to PyPI
|
|
50
|
+
if: startsWith(github.ref, 'refs/tags')
|
|
51
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
nnserve-0.0.1/.gitignore
ADDED
nnserve-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nnserve
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Inference serving for neural network models
|
|
5
|
+
Author-email: Jiau Zhang <jiauzhang@163.com>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: fastapi>=0.115
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: nnm
|
|
10
|
+
Requires-Dist: numpy>=2.0
|
|
11
|
+
Requires-Dist: opencv-python-headless>=4.11
|
|
12
|
+
Requires-Dist: pillow>=11.0
|
|
13
|
+
Requires-Dist: python-multipart>=0.0.20
|
|
14
|
+
Requires-Dist: tokenizers>=0.22
|
|
15
|
+
Requires-Dist: uvicorn>=0.30
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.17.0; extra == 'dev'
|
|
19
|
+
Provides-Extra: pdf
|
|
20
|
+
Requires-Dist: pypdfium2>=4.30; extra == 'pdf'
|
nnserve-0.0.1/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Inference serving for neural network models: one FastAPI service that exposes
|
|
2
|
+
OCR, speech synthesis, transcription and an OpenAI-compatible Responses path,
|
|
3
|
+
each backed by a registered model.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```shell
|
|
8
|
+
pip install nnserve
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The `pdf` extra adds page rendering for PDF inputs:
|
|
12
|
+
|
|
13
|
+
```shell
|
|
14
|
+
pip install "nnserve[pdf]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Sponsor
|
|
18
|
+
|
|
19
|
+
<table align="center">
|
|
20
|
+
<thead>
|
|
21
|
+
<tr>
|
|
22
|
+
<th colspan="2">公众号</th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody align="center" valign="center">
|
|
26
|
+
<tr>
|
|
27
|
+
<td colspan="2"><img src="https://jiauzhang.github.io/ghstatic/images/ofa_m.png" style="height: 196px" alt="AliPay.png"></td>
|
|
28
|
+
</tr>
|
|
29
|
+
</tbody>
|
|
30
|
+
<thead>
|
|
31
|
+
<tr>
|
|
32
|
+
<th>AliPay</th>
|
|
33
|
+
<th>WeChatPay</th>
|
|
34
|
+
</tr>
|
|
35
|
+
</thead>
|
|
36
|
+
<tbody align="center" valign="center">
|
|
37
|
+
<tr>
|
|
38
|
+
<td><img src="https://jiauzhang.github.io/AliPay.png" style="width: 196px; height: 196px" alt="AliPay.png"></td>
|
|
39
|
+
<td><img src="https://jiauzhang.github.io/WeChatPay.png" style="width: 196px; height: 196px" alt="WeChatPay.png"></td>
|
|
40
|
+
</tr>
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
@@ -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"]
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|