Skip to content

Commit 56e328b

Browse files
authored
feat!: v1 api with list of sources and target (#249)
Signed-off-by: Michele Dolfi <[email protected]>
1 parent daa924a commit 56e328b

23 files changed

+562
-373
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ Running [Docling](https://github.com/docling-project/docling) as an API service.
1515
- Explore usefule [deployment examples](./docs/deployment.md)
1616
- And more
1717

18+
> [!NOTE] Migration to the `v1` API
19+
> Docling Serve now has a stable v1 API. Read more on the [migration to v1](./docs/v1_migration.md).
20+
1821
## Getting started
1922

2023
Install the `docling-serve` package and run the server.
@@ -39,7 +42,7 @@ Try it out with a simple conversion:
3942

4043
```bash
4144
curl -X 'POST' \
42-
'http://localhost:5001/v1alpha/convert/source' \
45+
'http://localhost:5001/v1/convert/source' \
4346
-H 'accept: application/json' \
4447
-H 'Content-Type: application/json' \
4548
-d '{

docling_serve/app.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
BackgroundTasks,
1212
Depends,
1313
FastAPI,
14+
Form,
1415
HTTPException,
1516
Query,
1617
UploadFile,
@@ -32,7 +33,9 @@
3233
ProgressCallbackRequest,
3334
ProgressCallbackResponse,
3435
)
36+
from docling_jobkit.datamodel.http_inputs import FileSource, HttpSource
3537
from docling_jobkit.datamodel.task import Task, TaskSource
38+
from docling_jobkit.datamodel.task_targets import InBodyTarget, TaskTarget, ZipTarget
3639
from docling_jobkit.orchestrators.base_orchestrator import (
3740
BaseOrchestrator,
3841
ProgressInvalid,
@@ -41,9 +44,10 @@
4144

4245
from docling_serve.datamodel.convert import ConvertDocumentsRequestOptions
4346
from docling_serve.datamodel.requests import (
44-
ConvertDocumentFileSourcesRequest,
45-
ConvertDocumentHttpSourcesRequest,
4647
ConvertDocumentsRequest,
48+
FileSourceRequest,
49+
HttpSourceRequest,
50+
TargetName,
4751
)
4852
from docling_serve.datamodel.responses import (
4953
ClearResponse,
@@ -237,20 +241,24 @@ async def _enque_source(
237241
orchestrator: BaseOrchestrator, conversion_request: ConvertDocumentsRequest
238242
) -> Task:
239243
sources: list[TaskSource] = []
240-
if isinstance(conversion_request, ConvertDocumentFileSourcesRequest):
241-
sources.extend(conversion_request.file_sources)
242-
if isinstance(conversion_request, ConvertDocumentHttpSourcesRequest):
243-
sources.extend(conversion_request.http_sources)
244+
for s in conversion_request.sources:
245+
if isinstance(s, FileSourceRequest):
246+
sources.append(FileSource.model_validate(s))
247+
elif isinstance(s, HttpSourceRequest):
248+
sources.append(HttpSource.model_validate(s))
244249

245250
task = await orchestrator.enqueue(
246-
sources=sources, options=conversion_request.options
251+
sources=sources,
252+
options=conversion_request.options,
253+
target=conversion_request.target,
247254
)
248255
return task
249256

250257
async def _enque_file(
251258
orchestrator: BaseOrchestrator,
252259
files: list[UploadFile],
253260
options: ConvertDocumentsRequestOptions,
261+
target: TaskTarget,
254262
) -> Task:
255263
_log.info(f"Received {len(files)} files for processing.")
256264

@@ -262,7 +270,9 @@ async def _enque_file(
262270
name = file.filename if file.filename else f"file{suffix}.pdf"
263271
file_sources.append(DocumentStream(name=name, stream=buf))
264272

265-
task = await orchestrator.enqueue(sources=file_sources, options=options)
273+
task = await orchestrator.enqueue(
274+
sources=file_sources, options=options, target=target
275+
)
266276
return task
267277

268278
async def _wait_task_complete(orchestrator: BaseOrchestrator, task_id: str) -> bool:
@@ -300,7 +310,7 @@ def api_check() -> HealthCheckResponse:
300310

301311
# Convert a document from URL(s)
302312
@app.post(
303-
"/v1alpha/convert/source",
313+
"/v1/convert/source",
304314
response_model=ConvertDocumentResponse,
305315
responses={
306316
200: {
@@ -336,7 +346,7 @@ async def process_url(
336346

337347
# Convert a document from file(s)
338348
@app.post(
339-
"/v1alpha/convert/file",
349+
"/v1/convert/file",
340350
response_model=ConvertDocumentResponse,
341351
responses={
342352
200: {
@@ -351,9 +361,11 @@ async def process_file(
351361
options: Annotated[
352362
ConvertDocumentsRequestOptions, FormDepends(ConvertDocumentsRequestOptions)
353363
],
364+
target_type: Annotated[TargetName, Form()] = TargetName.INBODY,
354365
):
366+
target = InBodyTarget() if target_type == TargetName.INBODY else ZipTarget()
355367
task = await _enque_file(
356-
orchestrator=orchestrator, files=files, options=options
368+
orchestrator=orchestrator, files=files, options=options, target=target
357369
)
358370
completed = await _wait_task_complete(
359371
orchestrator=orchestrator, task_id=task.task_id
@@ -374,7 +386,7 @@ async def process_file(
374386

375387
# Convert a document from URL(s) using the async api
376388
@app.post(
377-
"/v1alpha/convert/source/async",
389+
"/v1/convert/source/async",
378390
response_model=TaskStatusResponse,
379391
)
380392
async def process_url_async(
@@ -396,7 +408,7 @@ async def process_url_async(
396408

397409
# Convert a document from file(s) using the async api
398410
@app.post(
399-
"/v1alpha/convert/file/async",
411+
"/v1/convert/file/async",
400412
response_model=TaskStatusResponse,
401413
)
402414
async def process_file_async(
@@ -406,9 +418,11 @@ async def process_file_async(
406418
options: Annotated[
407419
ConvertDocumentsRequestOptions, FormDepends(ConvertDocumentsRequestOptions)
408420
],
421+
target_type: Annotated[TargetName, Form()] = TargetName.INBODY,
409422
):
423+
target = InBodyTarget() if target_type == TargetName.INBODY else ZipTarget()
410424
task = await _enque_file(
411-
orchestrator=orchestrator, files=files, options=options
425+
orchestrator=orchestrator, files=files, options=options, target=target
412426
)
413427
task_queue_position = await orchestrator.get_queue_position(
414428
task_id=task.task_id
@@ -422,7 +436,7 @@ async def process_file_async(
422436

423437
# Task status poll
424438
@app.get(
425-
"/v1alpha/status/poll/{task_id}",
439+
"/v1/status/poll/{task_id}",
426440
response_model=TaskStatusResponse,
427441
)
428442
async def task_status_poll(
@@ -446,7 +460,7 @@ async def task_status_poll(
446460

447461
# Task status websocket
448462
@app.websocket(
449-
"/v1alpha/status/ws/{task_id}",
463+
"/v1/status/ws/{task_id}",
450464
)
451465
async def task_status_ws(
452466
websocket: WebSocket,
@@ -510,7 +524,7 @@ async def task_status_ws(
510524

511525
# Task result
512526
@app.get(
513-
"/v1alpha/result/{task_id}",
527+
"/v1/result/{task_id}",
514528
response_model=ConvertDocumentResponse,
515529
responses={
516530
200: {
@@ -534,7 +548,7 @@ async def task_result(
534548

535549
# Update task progress
536550
@app.post(
537-
"/v1alpha/callback/task/progress",
551+
"/v1/callback/task/progress",
538552
response_model=ProgressCallbackResponse,
539553
)
540554
async def callback_task_progress(
@@ -555,7 +569,7 @@ async def callback_task_progress(
555569

556570
# Offload models
557571
@app.get(
558-
"/v1alpha/clear/converters",
572+
"/v1/clear/converters",
559573
response_model=ClearResponse,
560574
)
561575
async def clear_converters(
@@ -566,7 +580,7 @@ async def clear_converters(
566580

567581
# Clean results
568582
@app.get(
569-
"/v1alpha/clear/results",
583+
"/v1/clear/results",
570584
response_model=ClearResponse,
571585
)
572586
async def clear_results(

docling_serve/datamodel/requests.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,38 @@
1-
from typing import Union
1+
import enum
2+
from typing import Annotated, Literal
23

3-
from pydantic import BaseModel
4+
from pydantic import BaseModel, Field
45

56
from docling_jobkit.datamodel.http_inputs import FileSource, HttpSource
7+
from docling_jobkit.datamodel.task_targets import InBodyTarget, TaskTarget, ZipTarget
68

79
from docling_serve.datamodel.convert import ConvertDocumentsRequestOptions
810

11+
## Sources
912

10-
class DocumentsConvertBase(BaseModel):
11-
options: ConvertDocumentsRequestOptions = ConvertDocumentsRequestOptions()
13+
14+
class FileSourceRequest(FileSource):
15+
kind: Literal["file"] = "file"
1216

1317

14-
class ConvertDocumentHttpSourcesRequest(DocumentsConvertBase):
15-
http_sources: list[HttpSource]
18+
class HttpSourceRequest(HttpSource):
19+
kind: Literal["http"] = "http"
1620

1721

18-
class ConvertDocumentFileSourcesRequest(DocumentsConvertBase):
19-
file_sources: list[FileSource]
22+
## Multipart targets
23+
class TargetName(str, enum.Enum):
24+
INBODY = InBodyTarget().kind
25+
ZIP = ZipTarget().kind
2026

2127

22-
ConvertDocumentsRequest = Union[
23-
ConvertDocumentFileSourcesRequest, ConvertDocumentHttpSourcesRequest
28+
## Aliases
29+
SourceRequestItem = Annotated[
30+
FileSourceRequest | HttpSourceRequest, Field(discriminator="kind")
2431
]
32+
33+
34+
## Complete Source request
35+
class ConvertDocumentsRequest(BaseModel):
36+
options: ConvertDocumentsRequestOptions = ConvertDocumentsRequestOptions()
37+
sources: list[SourceRequestItem]
38+
target: TaskTarget = InBodyTarget()

docling_serve/gradio_ui.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ def wait_task_finish(task_id: str, return_as_file: bool):
241241
while not task_finished:
242242
try:
243243
response = httpx.get(
244-
f"{get_api_endpoint()}/v1alpha/status/poll/{task_id}?wait=5",
244+
f"{get_api_endpoint()}/v1/status/poll/{task_id}?wait=5",
245245
verify=ssl_ctx,
246246
timeout=15,
247247
)
@@ -264,7 +264,7 @@ def wait_task_finish(task_id: str, return_as_file: bool):
264264
if conversion_sucess:
265265
try:
266266
response = httpx.get(
267-
f"{get_api_endpoint()}/v1alpha/result/{task_id}",
267+
f"{get_api_endpoint()}/v1/result/{task_id}",
268268
timeout=15,
269269
verify=ssl_ctx,
270270
)
@@ -296,8 +296,11 @@ def process_url(
296296
do_picture_classification,
297297
do_picture_description,
298298
):
299+
target = {"kind": "zip" if return_as_file else "inbody"}
299300
parameters = {
300-
"http_sources": [{"url": source} for source in input_sources.split(",")],
301+
"sources": [
302+
{"kind": "http", "url": source} for source in input_sources.split(",")
303+
],
301304
"options": {
302305
"to_formats": to_formats,
303306
"image_export_mode": image_export_mode,
@@ -309,24 +312,24 @@ def process_url(
309312
"pdf_backend": pdf_backend,
310313
"table_mode": table_mode,
311314
"abort_on_error": abort_on_error,
312-
"return_as_file": return_as_file,
313315
"do_code_enrichment": do_code_enrichment,
314316
"do_formula_enrichment": do_formula_enrichment,
315317
"do_picture_classification": do_picture_classification,
316318
"do_picture_description": do_picture_description,
317319
},
320+
"target": target,
318321
}
319322
if (
320-
not parameters["http_sources"]
321-
or len(parameters["http_sources"]) == 0
322-
or parameters["http_sources"][0]["url"] == ""
323+
not parameters["sources"]
324+
or len(parameters["sources"]) == 0
325+
or parameters["sources"][0]["url"] == ""
323326
):
324327
logger.error("No input sources provided.")
325328
raise gr.Error("No input sources provided.", print_exception=False)
326329
try:
327330
ssl_ctx = get_ssl_context()
328331
response = httpx.post(
329-
f"{get_api_endpoint()}/v1alpha/convert/source/async",
332+
f"{get_api_endpoint()}/v1/convert/source/async",
330333
json=parameters,
331334
verify=ssl_ctx,
332335
timeout=60,
@@ -372,11 +375,13 @@ def process_file(
372375
logger.error("No files provided.")
373376
raise gr.Error("No files provided.", print_exception=False)
374377
files_data = [
375-
{"base64_string": file_to_base64(file), "filename": file.name} for file in files
378+
{"kind": "file", "base64_string": file_to_base64(file), "filename": file.name}
379+
for file in files
376380
]
381+
target = {"kind": "zip" if return_as_file else "inbody"}
377382

378383
parameters = {
379-
"file_sources": files_data,
384+
"sources": files_data,
380385
"options": {
381386
"to_formats": to_formats,
382387
"image_export_mode": image_export_mode,
@@ -394,12 +399,13 @@ def process_file(
394399
"do_picture_classification": do_picture_classification,
395400
"do_picture_description": do_picture_description,
396401
},
402+
"target": target,
397403
}
398404

399405
try:
400406
ssl_ctx = get_ssl_context()
401407
response = httpx.post(
402-
f"{get_api_endpoint()}/v1alpha/convert/source/async",
408+
f"{get_api_endpoint()}/v1/convert/source/async",
403409
json=parameters,
404410
verify=ssl_ctx,
405411
timeout=60,

docling_serve/response_preparation.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from docling_core.types.doc import ImageRefMode
1616
from docling_jobkit.datamodel.convert import ConvertDocumentsOptions
1717
from docling_jobkit.datamodel.task import Task
18+
from docling_jobkit.datamodel.task_targets import InBodyTarget, TaskTarget
1819
from docling_jobkit.orchestrators.base_orchestrator import (
1920
BaseOrchestrator,
2021
)
@@ -139,6 +140,7 @@ def _export_documents_as_files(
139140

140141
def process_results(
141142
conversion_options: ConvertDocumentsOptions,
143+
target: TaskTarget,
142144
conv_results: Iterable[ConversionResult],
143145
work_dir: Path,
144146
) -> Union[ConvertDocumentResponse, FileResponse]:
@@ -175,7 +177,7 @@ def process_results(
175177
export_doctags = OutputFormat.DOCTAGS in conversion_options.to_formats
176178

177179
# Only 1 document was processed, and we are not returning it as a file
178-
if len(conv_results) == 1 and not conversion_options.return_as_file:
180+
if len(conv_results) == 1 and isinstance(target, InBodyTarget):
179181
conv_res = conv_results[0]
180182
document = _export_document_as_content(
181183
conv_res,
@@ -252,6 +254,7 @@ async def prepare_response(
252254
work_dir = get_scratch() / task.task_id
253255
response = process_results(
254256
conversion_options=task.options,
257+
target=task.target,
255258
conv_results=task.results,
256259
work_dir=work_dir,
257260
)

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ This documentation pages explore the webserver configurations, runtime options,
66
- [Advance usage](./usage.md)
77
- [Deployment](./deployment.md)
88
- [Development](./development.md)
9+
- [`v1` migration](./v1_migration.md)

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,6 @@ The following table describes the options to configure the Docling Serve KFP eng
7676
| `DOCLING_SERVE_ENG_KFP_ENDPOINT` | | Must be set to the Kubeflow Pipeline endpoint. When using the in-cluster deployment, make sure to use the cluster endpoint, e.g. `https://NAME.NAMESPACE.svc.cluster.local:8888` |
7777
| `DOCLING_SERVE_ENG_KFP_TOKEN` | | The authentication token for KFP. For in-cluster deployment, the app will load automatically the token of the ServiceAccount. |
7878
| `DOCLING_SERVE_ENG_KFP_CA_CERT_PATH` | | Path to the CA certificates for the KFP endpoint. For in-cluster deployment, the app will load automatically the internal CA. |
79-
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_ENDPOINT` | | If set, it enables internal callbacks providing status update of the KFP job. Usually something like `https://NAME.NAMESPACE.svc.cluster.local:5001/v1alpha/callback/task/progress`. |
79+
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_ENDPOINT` | | If set, it enables internal callbacks providing status update of the KFP job. Usually something like `https://NAME.NAMESPACE.svc.cluster.local:5001/v1/callback/task/progress`. |
8080
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_TOKEN_PATH` | | The token used for authenticating the progress callback. For cluster-internal workloads, use `/run/secrets/kubernetes.io/serviceaccount/token`. |
8181
| `DOCLING_SERVE_ENG_KFP_SELF_CALLBACK_CA_CERT_PATH` | | The CA certificate for the progress callback. For cluster-inetrnal workloads, use `/var/run/secrets/kubernetes.io/serviceaccount/service-ca.crt`. |

0 commit comments

Comments
 (0)