mirror of
https://github.com/opendatalab/MinerU.git
synced 2026-10-24 01:23:04 +08:00
refactor(api): unify task file fields and generate optional client IDs
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# V1 HTTP API
|
||||
|
||||
Tasks is the single asynchronous parsing API. API Server, Router, the SDK and WebUI use a JSON manifest, upload only missing files, poll task metadata and download each file's result. See the [Tasks contract](../../next/api/tasks.md) and [Python SDK](sdk_api.md).
|
||||
Tasks is the single asynchronous parsing API. API Server, Router, the SDK and WebUI use a JSON `files` array, upload only missing files, poll task metadata and download each file's result. See the [Tasks contract](../../next/api/tasks.md) and [Python SDK](sdk_api.md).
|
||||
|
||||
```bash
|
||||
mineru-kit api-server --host 127.0.0.1 --port 8000 --tier standard
|
||||
@@ -10,7 +10,7 @@ OUTPUT_FORMATS=default,docx MINERU_API_URL=http://127.0.0.1:8000 bash scripts/ht
|
||||
|
||||
## Create and upload
|
||||
|
||||
Send application/json to POST `/v1/tasks`. Each manifest entry contains a unique client_file_id, filename, bytes for uploaded files, and optional sha256sum/page_range. URL entries may omit bytes. Options are tier, ocr_mode, page_range, output_formats and include_model_output.
|
||||
Send application/json to POST `/v1/tasks`. Each `files` entry requires `file_name` and, for uploaded files, `bytes`; `sha256sum`, `page_range`, and `client_file_id` are optional. Omitted or null IDs are assigned the smallest available `fN`, starting at `f1`, in input order after reserving all explicit IDs. IDs remain stable in task responses and upload links. URL entries may omit bytes. Options are tier, ocr_mode, page_range, output_formats and include_model_output.
|
||||
|
||||
The response contains task_id, status_url and upload links for missing files. Follow each returned PUT URL and its headers; no completion request is needed. Each ready file queues independently. Waiting for uploads consumes no parsing slot.
|
||||
|
||||
@@ -34,4 +34,4 @@ Both shell examples delegate to the standard-library Python Tasks client. RESULT
|
||||
|
||||
Creation is not automatically retried. MAX_POLLS/POLL_INTERVAL bound polling; CONNECT_TIMEOUT/REQUEST_TIMEOUT bound individual requests. Exit codes are 0 (success), 2 (partial success), 1 (failure or cancellation) and 124 (poll budget exhausted). Completed downloads remain on disk.
|
||||
|
||||
The standalone upload and Jobs APIs have been removed. Coordinate server, Router and SDK upgrades; see [migration notes](../../next/api/changes.md). The separate [file_parse wrapper](../../next/api/file-parse.md) provides recoverable synchronous single-file calls.
|
||||
Tasks and Files JSON use `file_name`; creation accepts `files`. Old `manifest`/`filename` fields and mixed old/new fields are rejected with 400. The standalone upload and Jobs APIs have been removed. Coordinate server, Router and SDK upgrades; see [migration notes](../../next/api/changes.md). The separate [file_parse wrapper](../../next/api/file-parse.md) provides recoverable synchronous single-file calls.
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
| 旧方式 | 新方式 |
|
||||
|---|---|
|
||||
| Tasks 创建请求 `manifest` | `files`,仍为 1–100 项数组;旧字段或新旧混传返回 400。 |
|
||||
| Tasks / Files JSON `filename` | `file_name`;下载附件命名规则保持不变。 |
|
||||
| 创建请求必填 `client_file_id` | 可省略或为 `null`;服务端按输入顺序补全最小可用的 `fN`,避开所有显式 ID,响应仍必填。 |
|
||||
| 创建 Upload、PUT、complete | POST `/v1/tasks` 提交清单,只 PUT 缺失文件;上传成功自动排队。 |
|
||||
| 创建、查询、取消 Job | POST/GET/DELETE Tasks;GET `/v1/tasks` 列出父任务。 |
|
||||
| `files[].output_files` | `files[].result_url`,每个输入一个完整产物链接。 |
|
||||
@@ -15,4 +18,4 @@
|
||||
`--allow-local-source` 和 `--max-inline-bytes` 同时删除。Files CRUD、Health、Tiers、Models、Usage 保留;同步 `file_parse` 继续作为独立包装服务运行。
|
||||
SDK 保留 `MinerUApiParser` 方法和返回类型,状态类型改为 `ApiTaskStatus`,保留 `ApiJobStatus` Python 别名。直接本地 parser 与 HF CPU/ZeroGPU 内嵌解析不经过 HTTP。
|
||||
|
||||
停收旧请求、等待在途任务完成,再协调升级 worker、Router、SDK 和应用。SaaS Go 服务需部署方按同一契约配套更新;进程内任务和缓存索引不保证跨重启恢复。
|
||||
停收旧请求、等待在途任务完成,再协调升级 worker、Router、SDK、file_parse 包装服务和应用。SaaS Go 服务需部署方按同一契约配套更新;进程内任务和缓存索引不保证跨重启恢复。
|
||||
|
||||
@@ -11,7 +11,7 @@ TASK_ID=job_... MINERU_API_URL=http://127.0.0.1:8000 bash scripts/http_task_exam
|
||||
直接提供 URL 时只需提交 JSON:
|
||||
|
||||
```json
|
||||
{"manifest":[{"client_file_id":"report","filename":"report.pdf","url":"https://example.com/report.pdf"}],"output_formats":["default"],"include_model_output":false}
|
||||
{"files":[{"client_file_id":"report","file_name":"report.pdf","url":"https://example.com/report.pdf"}],"output_formats":["default"],"include_model_output":false}
|
||||
```
|
||||
|
||||
对 `/v1/tasks` 执行 POST,保存返回的 `task_id/status_url`。本地文件清单包含 `bytes` 和可选 `sha256sum`;仅对响应中的待上传项使用返回的 PUT 地址。
|
||||
|
||||
@@ -9,7 +9,7 @@ Files 保存源文件视图与已生成的结果。异步解析统一使用 [Tas
|
||||
| GET | `/v1/files/{file_id}/content` | 流式下载已保存的解析产物;源文件不提供公共下载。 |
|
||||
| DELETE | `/v1/files/{file_id}` | 删除文件视图;正在执行和下载的引用继续受保护。 |
|
||||
|
||||
元数据包括 `id/object/filename/bytes/created_at/expires_at/purpose/sha256sum`。
|
||||
元数据包括 `id/object/file_name/bytes/created_at/expires_at/purpose/sha256sum`。
|
||||
`purpose` 为 `parse`(源文件)或 `parse_output`(结果);删除返回 `id/object/deleted`。
|
||||
列表使用 `object: list`、`data`、`first_id`、`last_id`、`has_more`;默认 20 条,上限 100。
|
||||
|
||||
|
||||
+19
-14
@@ -21,7 +21,7 @@ Task ID 是父任务标识;内部逐文件执行 ID 不属于公共契约。
|
||||
|
||||
| JSON 字段 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `manifest` | 必填 | 1–100 个文件组成的 JSON 数组,不接受 JSON 字符串。 |
|
||||
| `files` | 必填 | 1–100 个文件组成的 JSON 数组,不接受 JSON 字符串。 |
|
||||
| `tier` | 服务默认值 | `flash/basic/standard/advanced`,受部署能力约束。 |
|
||||
| `ocr_mode` | `auto` | `auto/txt/ocr`。 |
|
||||
| `page_range` | 整本 | 全局 PDF 页范围,支持 `1-5`、`r1`、`all` 等现有语法。 |
|
||||
@@ -33,14 +33,17 @@ Task ID 是父任务标识;内部逐文件执行 ID 不属于公共契约。
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `client_file_id` | 是 | 任务内唯一,1–64 个字母、数字、下划线或短横线。 |
|
||||
| `filename` | 是 | 逻辑文件名,保留扩展名;不同条目允许同名。 |
|
||||
| `client_file_id` | 否 | 任务内唯一,1–64 个字母、数字、下划线或短横线;省略或 `null` 时由服务端补全。 |
|
||||
| `file_name` | 是 | 逻辑文件名,保留扩展名;不同条目允许同名。 |
|
||||
| `url` | 否 | HTTP(S) 文件下载地址,受部署来源策略约束;省略或 `null` 表示客户端上传。 |
|
||||
| `bytes` | 条件 | 上传项必填;URL 项可省略或为 `null`。提供时必须是严格正整数。 |
|
||||
| `sha256sum` | 否 | 64 位小写十六进制 SHA-256;用于完整性校验,上传项还用于源文件缓存预检。 |
|
||||
| `page_range` | 否 | 逐文件 PDF 页范围,未提供时继承全局值;非 PDF 仍只支持整本解析。 |
|
||||
|
||||
先从真实文件生成 JSON 请求:
|
||||
服务端先预留全部显式 `client_file_id`,再按缺省项的输入顺序,从 `f1` 开始分配最小未占用的 `fN`。例如显式占用了 `f2`,两项缺省 ID 分别生成 `f1`、`f3`。空字符串、非法 ID 和重复显式 ID 返回 400。
|
||||
创建响应的 `files` 顺序与输入一致,`files[]` 和 `uploads[]` 始终返回非空 ID;生成后上传、查询及 Router 转发均保持不变。
|
||||
|
||||
先从真实文件生成 JSON 请求(本例省略 ID,由服务端补全):
|
||||
|
||||
```bash
|
||||
python3 - a.pdf b.pdf > task.json <<'PY'
|
||||
@@ -49,20 +52,19 @@ import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
manifest = []
|
||||
for index, value in enumerate(sys.argv[1:], start=1):
|
||||
files = []
|
||||
for value in sys.argv[1:]:
|
||||
path = Path(value)
|
||||
hasher = hashlib.sha256()
|
||||
with path.open('rb') as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b''):
|
||||
hasher.update(chunk)
|
||||
manifest.append({
|
||||
'client_file_id': f'f{index}',
|
||||
'filename': path.name,
|
||||
files.append({
|
||||
'file_name': path.name,
|
||||
'bytes': path.stat().st_size,
|
||||
'sha256sum': hasher.hexdigest(),
|
||||
})
|
||||
print(json.dumps({'manifest': manifest, 'tier': 'flash', 'ocr_mode': 'txt',
|
||||
print(json.dumps({'files': files, 'tier': 'flash', 'ocr_mode': 'txt',
|
||||
'output_formats': ['default', 'latex']}, ensure_ascii=False))
|
||||
PY
|
||||
|
||||
@@ -80,16 +82,16 @@ URL 输入同样先创建 JSON 任务:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": [
|
||||
{"client_file_id": "remote", "filename": "document.pdf", "url": "https://example.com/download?signature=example"},
|
||||
{"client_file_id": "local", "filename": "other.pdf", "bytes": 12345}
|
||||
"files": [
|
||||
{"client_file_id": "remote", "file_name": "document.pdf", "url": "https://example.com/download?signature=example"},
|
||||
{"client_file_id": "local", "file_name": "other.pdf", "bytes": 12345}
|
||||
],
|
||||
"output_formats": ["html"]
|
||||
}
|
||||
```
|
||||
|
||||
- URL 项的 `upload_status` 为 `not_required`,不生成上传链接、不接受 PUT;源 `file_id` 为 `null`。创建后即进入执行队列,不等待其他上传。
|
||||
- URL 保留原始签名地址,使用逻辑 `filename` 识别类型;不要求 HEAD 或 `Content-Length`。下载后返回实际 `bytes` 和 `sha256sum`,提供的大小/hash 必须匹配。URL 每次下载,不用 hash 缓存跳过下载。
|
||||
- URL 保留原始签名地址,使用逻辑 `file_name` 识别类型;不要求 HEAD 或 `Content-Length`。下载后返回实际 `bytes` 和 `sha256sum`,提供的大小/hash 必须匹配。URL 每次下载,不用 hash 缓存跳过下载。
|
||||
- 上传项 hash 命中时为 `ready`,不返回上传链接,立即排队;未命中或无 hash 时返回该文件的链接。上传后仍计算 hash、校验大小并对源字节去重。
|
||||
- 缓存只复用源文件,不复用不匹配的解析结果;每个文件使用本次解析参数独立执行。
|
||||
- 每个上传项校验完成即排队,不需要客户端发送完成确认。等待上传不占解析并发,也不预占 worker。
|
||||
@@ -221,6 +223,9 @@ URL 交给 worker 下载,客户端上传字节按 worker 的缓存预检结果
|
||||
不声明新 Tasks 能力的旧 worker 不参与 Tasks 调度,更新时应先更新 worker,再更新 Router 和客户端。
|
||||
Router 使用有界、可续租的内部结果租约保护 worker 产物,上传窗口不受结果租约影响;Router 丢失后租约会自行结束。已完成任务按 worker 并行续租,每轮网络等待最多 10 秒,避免失联 worker 阻塞其他 worker 的结果保留。
|
||||
|
||||
创建请求仅接受 `files` 和 `file_name`,不兼容 `manifest`、`filename`;新旧字段混传同样返回 400。Tasks 响应及 Files 列表、详情统一使用 `file_name`。
|
||||
这是同一 `/v1` 下的破坏性变更:先停收请求并排空在途任务,再协调更新 worker、Router、SDK、file_parse 包装服务及其他客户端;SaaS 由部署方同步对齐。
|
||||
|
||||
本次协议不保留旧表单、直传或 `markdown/middle_json/structured_content/zip` Tasks 格式别名。
|
||||
客户端应改用 JSON、`files[].result_url` 及逐文件保存方式。
|
||||
API Server 和 Router 不提供 `/v1/file_parse`、`/v1/tasks/{task_id}/result`。
|
||||
|
||||
@@ -18,7 +18,7 @@ print(result.markdown())
|
||||
## 请求与结果
|
||||
|
||||
1. 校验文件及页范围,计算大小和 SHA-256,包括同机 HTTP 请求。
|
||||
2. 向 `/v1/tasks` 提交一个 `client_file_id="file"` 的 manifest,固定请求 `output_formats=["default"]`。
|
||||
2. 向 `/v1/tasks` 提交一个 `client_file_id="file"` 的 files 数组,固定请求 `output_formats=["default"]`。
|
||||
3. 缓存命中不上传;未命中按服务返回的 PUT 链接和 headers 流式上传,并按清单大小设置 `Content-Length`。无需完成确认。
|
||||
4. 按返回的同源 `status_url` 每秒查询,轮询预算 3600 次;用 `client_file_id` 关联结果。
|
||||
5. 从该文件的 `result_url` 流式下载并校验 ZIP,读取 `middle_json.json`。
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
│
|
||||
└─ v1 API 调用
|
||||
-> Official API 或 Local Parse Server
|
||||
-> Tasks manifest / hash preflight / optional upload
|
||||
-> Tasks files / hash preflight / optional upload
|
||||
-> parse-server
|
||||
-> per-file result_url / requested formats
|
||||
```
|
||||
|
||||
@@ -10,7 +10,9 @@ OUTPUT_FORMATS=default,docx MINERU_API_URL=http://127.0.0.1:8000 bash scripts/ht
|
||||
|
||||
## 文件清单与上传
|
||||
|
||||
向 `POST /v1/tasks` 提交 `application/json`:`manifest` 每项包含唯一 `client_file_id`、逻辑 `filename`、上传文件的 `bytes` 和可选 `sha256sum/page_range`。也可以提供 URL,此时 bytes 可省略。解析选项为 `tier/ocr_mode/page_range/output_formats/include_model_output`。
|
||||
Tasks 与 Files JSON 统一使用 `file_name`;旧字段 `manifest`、`filename` 及新旧混传返回 400。
|
||||
|
||||
向 `POST /v1/tasks` 提交 `application/json`:`files` 每项包含必填逻辑 `file_name`、上传文件的 `bytes` 和可选 `client_file_id/sha256sum/page_range`。`client_file_id` 省略或为 `null` 时,先预留全部显式 ID,再按输入顺序从 `f1` 开始分配最小未占用的 `fN`;创建后上传和查询均保持不变。也可以提供 URL,此时 bytes 可省略。解析选项为 `tier/ocr_mode/page_range/output_formats/include_model_output`。
|
||||
|
||||
创建返回稳定的 `task_id/status_url` 和待上传项的链接。客户端只上传缺失文件,使用响应指定的 PUT 地址及请求头,无需 complete。每个文件就绪即独立排队,其他文件等待上传不会阻塞它。
|
||||
同机也使用相同流程。SDK 总是计算 hash;已完成上传的有效缓存可以免传。并发首次请求允许重复传输,但源字节去重。缓存默认保留 24 小时、每 5 分钟扫描,正在执行及传输的引用受保护。
|
||||
|
||||
@@ -175,10 +175,10 @@ class TaskClient:
|
||||
options = {key: value for key, value in record.options.items() if value is not None}
|
||||
payload = {
|
||||
**options,
|
||||
"manifest": [
|
||||
"files": [
|
||||
{
|
||||
"client_file_id": "file",
|
||||
"filename": record.filename,
|
||||
"file_name": record.filename,
|
||||
"bytes": record.size,
|
||||
"sha256sum": record.sha256sum,
|
||||
}
|
||||
|
||||
+20
-20
@@ -23,7 +23,7 @@ from ...parser.api_server import ApiServerError, ErrorDetail, _JobInputMetadata,
|
||||
from ...parser.task_api import (
|
||||
TERMINAL_TASK_STATUSES,
|
||||
TaskExecution,
|
||||
TaskManifestItem,
|
||||
TaskFileItem,
|
||||
TaskStatusResponse,
|
||||
TaskUpload,
|
||||
UploadTaskOptions,
|
||||
@@ -262,13 +262,13 @@ class RouterTaskBackend:
|
||||
result_route.metadata["payload"]["expires_at"] = route.metadata["task_result_retain_until"]
|
||||
result_route.metadata["task_result_retain_until"] = route.metadata["task_result_retain_until"]
|
||||
|
||||
async def validate(self, options: UploadTaskOptions, manifest: list[TaskManifestItem]) -> None:
|
||||
async def validate(self, options: UploadTaskOptions, inputs: list[TaskFileItem]) -> None:
|
||||
"""能力预检不推进轮询游标,也不为尚未上传的任务预占 worker。"""
|
||||
tiers = self.pool.available_tiers()
|
||||
options.tier = resolve_task_tier(options, manifest, tiers, select_default_quality_tier(tiers))
|
||||
options.tier = resolve_task_tier(options, inputs, tiers, select_default_quality_tier(tiers))
|
||||
checked_workers: set[str] = set()
|
||||
for item in manifest:
|
||||
lightweight = is_flash_only_parse_extension(item.filename)
|
||||
for item in inputs:
|
||||
lightweight = is_flash_only_parse_extension(item.file_name)
|
||||
selection_tier = "flash" if lightweight else options.tier
|
||||
candidates = self.pool.capable_workers(
|
||||
tier=selection_tier,
|
||||
@@ -324,7 +324,7 @@ class RouterTaskBackend:
|
||||
self._pins.close()
|
||||
|
||||
async def _wait_for_input_files(
|
||||
self, worker: WorkerState, upstream_id: str, generation: int, manifest: list[TaskManifestItem]
|
||||
self, worker: WorkerState, upstream_id: str, generation: int, inputs: list[TaskFileItem]
|
||||
) -> dict[str, Any]:
|
||||
"""等待已上传项生成公共 File;级联 Router 的上传就绪可能早于下游引用交接。"""
|
||||
while True:
|
||||
@@ -338,11 +338,11 @@ class RouterTaskBackend:
|
||||
)
|
||||
require_worker_generation(worker, generation)
|
||||
ready = TaskStatusResponse.model_validate(_checked_json(response)).model_dump()
|
||||
if ready["task_id"] != upstream_id or len(ready["files"]) != len(manifest) or ready["status"] == "waiting_upload":
|
||||
if ready["task_id"] != upstream_id or len(ready["files"]) != len(inputs) or ready["status"] == "waiting_upload":
|
||||
_raise_api_error(
|
||||
502, error_type="api_error", code="invalid_upstream_response", message="Worker inputs are not ready"
|
||||
)
|
||||
if any(item["client_file_id"] != source.client_file_id for item, source in zip(ready["files"], manifest)):
|
||||
if any(item["client_file_id"] != source.client_file_id for item, source in zip(ready["files"], inputs)):
|
||||
_raise_api_error(
|
||||
502, error_type="api_error", code="invalid_upstream_response", message="Worker input order changed"
|
||||
)
|
||||
@@ -351,7 +351,7 @@ class RouterTaskBackend:
|
||||
and item["upload_status"] == "ready"
|
||||
and item["file_id"] is None
|
||||
and item["status"] in {"queued", "running"}
|
||||
for item, source in zip(ready["files"], manifest)
|
||||
for item, source in zip(ready["files"], inputs)
|
||||
)
|
||||
if not pending:
|
||||
return ready
|
||||
@@ -359,17 +359,17 @@ class RouterTaskBackend:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def submit(
|
||||
self, task_id: str, options: UploadTaskOptions, manifest: list[TaskManifestItem], uploads: dict[int, TaskUpload]
|
||||
self, task_id: str, options: UploadTaskOptions, inputs: list[TaskFileItem], uploads: dict[int, TaskUpload]
|
||||
) -> TaskExecution:
|
||||
"""预占后提交 worker 任务,只向 worker 上传它实际缺失的文件。"""
|
||||
lightweight = all(is_flash_only_parse_extension(item.filename) for item in manifest)
|
||||
lightweight = all(is_flash_only_parse_extension(item.file_name) for item in inputs)
|
||||
tier = "flash" if lightweight else options.tier
|
||||
reservation = self.pool.reserve_job(
|
||||
tier=tier,
|
||||
required_sources={"url" if item.url is not None else "upload" for item in manifest},
|
||||
required_sources={"url" if item.url is not None else "upload" for item in inputs},
|
||||
required_task_outputs=set(options.output_formats),
|
||||
require_model_output=options.include_model_output,
|
||||
require_flash=any(is_flash_only_parse_extension(item.filename) for item in manifest),
|
||||
require_flash=any(is_flash_only_parse_extension(item.file_name) for item in inputs),
|
||||
)
|
||||
if reservation is None:
|
||||
_raise_api_error(
|
||||
@@ -381,11 +381,11 @@ class RouterTaskBackend:
|
||||
registered_inputs: list[str] = []
|
||||
transferred = False
|
||||
try:
|
||||
worker_manifest = [
|
||||
worker_inputs = [
|
||||
item.model_dump(exclude_none=True)
|
||||
if item.url is not None
|
||||
else {**item.model_dump(exclude_none=True), "sha256sum": uploads[index].sha256sum}
|
||||
for index, item in enumerate(manifest)
|
||||
for index, item in enumerate(inputs)
|
||||
]
|
||||
response = await _worker_request(
|
||||
self.pool,
|
||||
@@ -394,7 +394,7 @@ class RouterTaskBackend:
|
||||
"/v1/tasks",
|
||||
request=self.request,
|
||||
json_body={
|
||||
"manifest": worker_manifest,
|
||||
"files": worker_inputs,
|
||||
"tier": options.tier,
|
||||
"ocr_mode": options.ocr_mode,
|
||||
"output_formats": options.output_formats,
|
||||
@@ -420,7 +420,7 @@ class RouterTaskBackend:
|
||||
upstream_headers={"authorization": authorization} if authorization else {},
|
||||
payload={"job_id": task_id, "status": "queued", "files": []},
|
||||
)
|
||||
by_client_id = {item.client_file_id: index for index, item in enumerate(manifest)}
|
||||
by_client_id = {item.client_file_id: index for index, item in enumerate(inputs)}
|
||||
for link in payload["uploads"]:
|
||||
index = by_client_id.get(link["client_file_id"])
|
||||
if index not in uploads or link["upload_method"] != "PUT":
|
||||
@@ -449,10 +449,10 @@ class RouterTaskBackend:
|
||||
if response.status_code >= 400:
|
||||
_checked_json(response)
|
||||
require_worker_generation(worker, reservation.generation)
|
||||
ready = await self._wait_for_input_files(worker, upstream_id, reservation.generation, manifest)
|
||||
ready = await self._wait_for_input_files(worker, upstream_id, reservation.generation, inputs)
|
||||
aliases: dict[str, str] = {}
|
||||
for index, item in enumerate(ready["files"]):
|
||||
if manifest[index].url is not None:
|
||||
if inputs[index].url is not None:
|
||||
if item["upload_status"] != "not_required":
|
||||
_raise_api_error(
|
||||
502, error_type="api_error", code="invalid_upstream_response", message="Unexpected URL upload"
|
||||
@@ -468,7 +468,7 @@ class RouterTaskBackend:
|
||||
{
|
||||
"id": upstream_file_id,
|
||||
"object": "file",
|
||||
"filename": upload.filename,
|
||||
"file_name": upload.filename,
|
||||
"bytes": upload.bytes,
|
||||
"sha256sum": upload.sha256sum,
|
||||
"purpose": "parse",
|
||||
|
||||
@@ -162,10 +162,10 @@ class MinerUApiParser(DocumentParser):
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(path)
|
||||
payload: dict[str, Any] = {
|
||||
"manifest": [
|
||||
"files": [
|
||||
{
|
||||
"client_file_id": "file",
|
||||
"filename": path.name,
|
||||
"file_name": path.name,
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256sum": _sha256_file(path),
|
||||
"page_range": page_range,
|
||||
@@ -237,7 +237,7 @@ class MinerUApiParser(DocumentParser):
|
||||
)
|
||||
task = self._snapshot(response)
|
||||
_notify_job_status(task, status_callback)
|
||||
task = self._upload_input(cli, task, file_path, status_callback, size=payload["manifest"][0]["bytes"])
|
||||
task = self._upload_input(cli, task, file_path, status_callback, size=payload["files"][0]["bytes"])
|
||||
for _ in range(_POLL_MAX_ATTEMPTS):
|
||||
if task["status"] in _TERMINAL_JOB_STATUSES:
|
||||
break
|
||||
@@ -394,7 +394,7 @@ class MinerUApiParser(DocumentParser):
|
||||
)
|
||||
task = self._snapshot(response)
|
||||
_notify_job_status(task, status_callback)
|
||||
task = await self._async_upload_input(cli, task, file_path, status_callback, size=payload["manifest"][0]["bytes"])
|
||||
task = await self._async_upload_input(cli, task, file_path, status_callback, size=payload["files"][0]["bytes"])
|
||||
for _ in range(_POLL_MAX_ATTEMPTS):
|
||||
if task["status"] in _TERMINAL_JOB_STATUSES:
|
||||
break
|
||||
|
||||
@@ -308,7 +308,7 @@ class FileObjectModel(BaseModel):
|
||||
bytes: int
|
||||
created_at: int
|
||||
expires_at: int | None = None
|
||||
filename: str
|
||||
file_name: str
|
||||
purpose: FilePurpose
|
||||
sha256sum: str | None = None
|
||||
|
||||
@@ -704,7 +704,7 @@ class FileStore:
|
||||
bytes=rec.bytes,
|
||||
created_at=rec.created_at,
|
||||
expires_at=rec.expires_at,
|
||||
filename=rec.filename,
|
||||
file_name=rec.filename,
|
||||
purpose=rec.purpose,
|
||||
sha256sum=rec.sha256sum,
|
||||
)
|
||||
@@ -1414,9 +1414,9 @@ _OPENAPI_TAGS: list[dict[str, str]] = [
|
||||
_FASTAPI_DESCRIPTION = """\
|
||||
Parse documents into structured Middle JSON via self-hosted or remote capacity.
|
||||
|
||||
Submit a JSON manifest to `/v1/tasks`, upload missing files to their one-hour URLs,
|
||||
Submit a JSON files array to `/v1/tasks`, upload missing files to their one-hour URLs,
|
||||
then poll the task and download each completed file's `result_url`. Each file runs
|
||||
as soon as it is ready; other files may still be waiting for upload. Manifest
|
||||
as soon as it is ready; other files may still be waiting for upload. File
|
||||
entries can instead provide a source `url`, with optional `bytes`; workers download
|
||||
these inputs in the background. Choose one or more of default/docx/html/latex in
|
||||
`output_formats`. Task status contains metadata only, never inline result bytes.
|
||||
|
||||
+98
-69
@@ -54,19 +54,19 @@ TaskFileState = Literal["waiting_upload", "queued", "running", "completed", "fai
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskManifestItem(BaseModel):
|
||||
"""按客户端标识关联上传或 URL 输入,不依赖文件名唯一性。"""
|
||||
class TaskFileInput(BaseModel):
|
||||
"""声明上传或 URL 输入,客户端可以省略任务内文件标识。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
client_file_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,64}$")
|
||||
filename: str = Field(min_length=1, max_length=1024)
|
||||
client_file_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,64}$")
|
||||
file_name: str = Field(min_length=1, max_length=1024)
|
||||
bytes: int | None = Field(default=None, gt=0, strict=True)
|
||||
url: str | None = None
|
||||
sha256sum: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
|
||||
page_range: str | None = Field(default=None, description=PAGE_RANGE_DESCRIPTION)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_source(self) -> TaskManifestItem:
|
||||
def validate_source(self) -> TaskFileInput:
|
||||
"""上传项必须声明大小;URL 只校验合法性,保留签名地址的原始字符串。"""
|
||||
if self.url is None:
|
||||
if self.bytes is None:
|
||||
@@ -76,8 +76,14 @@ class TaskManifestItem(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class TaskFileItem(TaskFileInput):
|
||||
"""接单前补全文件标识,内部执行和状态响应只消费完整文件条目。"""
|
||||
|
||||
client_file_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
class UploadTaskOptions(BaseModel):
|
||||
"""解析选项仅来自 JSON 主体,文件元数据独立保存在 manifest 中。"""
|
||||
"""解析选项仅来自 JSON 主体,文件元数据独立保存在 files 中。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
tier: Tier | None = None
|
||||
@@ -97,20 +103,20 @@ class UploadTaskOptions(BaseModel):
|
||||
class CreateTaskRequest(UploadTaskOptions):
|
||||
"""严格 JSON 创建请求,清单是数组而不是编码后的字符串。"""
|
||||
|
||||
manifest: list[TaskManifestItem] = Field(min_length=1, max_length=MAX_TASK_FILES)
|
||||
files: list[TaskFileInput] = Field(min_length=1, max_length=MAX_TASK_FILES)
|
||||
|
||||
|
||||
class TaskUploadLink(BaseModel):
|
||||
"""描述一个文件的上传目标,客户端原样使用服务端给出的地址和请求头。"""
|
||||
|
||||
client_file_id: str
|
||||
client_file_id: str = Field(pattern=r"^[A-Za-z0-9_-]{1,64}$")
|
||||
upload_url: str
|
||||
upload_method: Literal["PUT"] = "PUT"
|
||||
upload_headers: dict[str, str]
|
||||
expires_at: int
|
||||
|
||||
|
||||
class TaskFileStatus(TaskManifestItem):
|
||||
class TaskFileStatus(TaskFileItem):
|
||||
"""同时提供上传准备情况与逐文件解析状态,禁止内联解析正文。"""
|
||||
|
||||
upload_status: Literal["pending", "ready", "not_required", "expired"]
|
||||
@@ -194,7 +200,7 @@ class TaskBackend(Protocol):
|
||||
"""父任务收尾后解除子结果租约,并开始正常保留期。"""
|
||||
...
|
||||
|
||||
async def validate(self, options: UploadTaskOptions, manifest: list[TaskManifestItem]) -> None:
|
||||
async def validate(self, options: UploadTaskOptions, inputs: list[TaskFileItem]) -> None:
|
||||
"""在接单之前校验部署能力,不能预占解析名额。"""
|
||||
...
|
||||
|
||||
@@ -211,7 +217,7 @@ class TaskBackend(Protocol):
|
||||
...
|
||||
|
||||
async def submit(
|
||||
self, task_id: str, options: UploadTaskOptions, manifest: list[TaskManifestItem], uploads: dict[int, TaskUpload]
|
||||
self, task_id: str, options: UploadTaskOptions, inputs: list[TaskFileItem], uploads: dict[int, TaskUpload]
|
||||
) -> TaskExecution:
|
||||
"""使用预先分配的公开 ID 提交一次后台解析。"""
|
||||
...
|
||||
@@ -236,13 +242,11 @@ def _invalid(message: str, *, code: str = "invalid_request", status_code: int =
|
||||
)
|
||||
|
||||
|
||||
def resolve_task_tier(
|
||||
options: UploadTaskOptions, manifest: list[TaskManifestItem], tiers: set[Tier], default: Tier | None
|
||||
) -> Tier:
|
||||
def resolve_task_tier(options: UploadTaskOptions, inputs: list[TaskFileItem], tiers: set[Tier], default: Tier | None) -> Tier:
|
||||
"""按现有轻量格式回退规则解析 tier,不触发 worker 选择或模型执行。"""
|
||||
lightweight = [is_flash_only_parse_extension(item.filename) for item in manifest]
|
||||
lightweight = [is_flash_only_parse_extension(item.file_name) for item in inputs]
|
||||
if any(lightweight) and "flash" not in tiers:
|
||||
_invalid("Flash parsing is disabled, but an input requires it", param="manifest")
|
||||
_invalid("Flash parsing is disabled, but an input requires it", param="files")
|
||||
tier = options.tier or default
|
||||
if tier is None and all(lightweight) and "flash" in tiers:
|
||||
tier = "flash"
|
||||
@@ -266,7 +270,7 @@ async def _save_upload(
|
||||
filename: str,
|
||||
mime_type: str,
|
||||
*,
|
||||
expected: TaskManifestItem | None = None,
|
||||
expected: TaskFileItem | None = None,
|
||||
canceled: Callable[[], bool] | None = None,
|
||||
) -> TaskUpload:
|
||||
"""流式落盘并校验完整性;声明大小用于一致性检查,不是上传大小上限。"""
|
||||
@@ -295,7 +299,31 @@ async def _save_upload(
|
||||
raise
|
||||
|
||||
|
||||
async def _read_submission(request: Request) -> tuple[UploadTaskOptions, list[TaskManifestItem]]:
|
||||
def _normalize_task_files(inputs: list[TaskFileInput]) -> list[TaskFileItem]:
|
||||
"""先预留全部显式标识,再按输入顺序给缺省项分配最小可用的 fN。"""
|
||||
used_ids: set[str] = set()
|
||||
for index, item in enumerate(inputs):
|
||||
if item.client_file_id is None:
|
||||
continue
|
||||
if item.client_file_id in used_ids:
|
||||
_invalid("client_file_id must be unique within a task", param=f"files.{index}.client_file_id")
|
||||
used_ids.add(item.client_file_id)
|
||||
|
||||
normalized: list[TaskFileItem] = []
|
||||
next_id = 1
|
||||
for item in inputs:
|
||||
client_file_id = item.client_file_id
|
||||
if client_file_id is None:
|
||||
while f"f{next_id}" in used_ids:
|
||||
next_id += 1
|
||||
client_file_id = f"f{next_id}"
|
||||
used_ids.add(client_file_id)
|
||||
next_id += 1
|
||||
normalized.append(TaskFileItem.model_validate({**item.model_dump(), "client_file_id": client_file_id}))
|
||||
return normalized
|
||||
|
||||
|
||||
async def _read_submission(request: Request) -> tuple[UploadTaskOptions, list[TaskFileItem]]:
|
||||
"""只读取 JSON 清单,严格拒绝表单、直传字节和 URL 查询参数。"""
|
||||
if request.query_params:
|
||||
_invalid("POST parameters must be supplied in the JSON body, not the URL")
|
||||
@@ -304,19 +332,18 @@ async def _read_submission(request: Request) -> tuple[UploadTaskOptions, list[Ta
|
||||
_invalid("Use application/json", status_code=415)
|
||||
try:
|
||||
body = CreateTaskRequest.model_validate(await request.json())
|
||||
if len({item.client_file_id for item in body.manifest}) != len(body.manifest):
|
||||
_invalid("client_file_id must be unique within a task", param="manifest")
|
||||
options = UploadTaskOptions.model_validate(body.model_dump(exclude={"manifest"}))
|
||||
inputs = _normalize_task_files(body.files)
|
||||
options = UploadTaskOptions.model_validate(body.model_dump(exclude={"files"}))
|
||||
options.page_range = normalize_page_range_input(options.page_range)
|
||||
options.output_formats = list(dict.fromkeys(options.output_formats))
|
||||
for index, item in enumerate(body.manifest):
|
||||
for index, item in enumerate(inputs):
|
||||
try:
|
||||
item.page_range = normalize_page_range_input(
|
||||
item.page_range if item.page_range is not None else options.page_range
|
||||
)
|
||||
except MineruError as exc:
|
||||
_invalid(str(exc), code=exc.code, param=f"manifest.{index}.page_range")
|
||||
return options, body.manifest
|
||||
_invalid(str(exc), code=exc.code, param=f"files.{index}.page_range")
|
||||
return options, inputs
|
||||
except ClientDisconnect:
|
||||
_invalid("Request interrupted", code="request_interrupted")
|
||||
except MineruError as exc:
|
||||
@@ -368,10 +395,10 @@ class _TaskRecord:
|
||||
id: str
|
||||
backend: TaskBackend
|
||||
options: UploadTaskOptions
|
||||
manifest: list[TaskManifestItem]
|
||||
inputs: list[TaskFileItem]
|
||||
directory: Path
|
||||
expires_at: int
|
||||
files: list[_TaskFileRecord]
|
||||
file_states: list[_TaskFileRecord]
|
||||
created_at: str = field(default_factory=JobStore._now)
|
||||
status: TaskStatus = "waiting_upload"
|
||||
refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
@@ -413,7 +440,7 @@ class TaskStore:
|
||||
if record.status == "canceled":
|
||||
return
|
||||
previous = record.status
|
||||
states = [file.status for file in record.files]
|
||||
states = [file.status for file in record.file_states]
|
||||
if "running" in states:
|
||||
record.status = "running"
|
||||
elif "queued" in states:
|
||||
@@ -433,7 +460,7 @@ class TaskStore:
|
||||
"""只终结尚未受理上传的文件;截止前已经受理的流允许继续完成。"""
|
||||
if record.expires_at > time.time() or record.status == "canceled":
|
||||
return
|
||||
for file in record.files:
|
||||
for file in record.file_states:
|
||||
if file.status == "waiting_upload" and not file.upload_active:
|
||||
file.expired = True
|
||||
file.status = "failed"
|
||||
@@ -508,7 +535,10 @@ class TaskStore:
|
||||
]
|
||||
page = records[:limit]
|
||||
return TaskListResponse(
|
||||
data=[TaskListItem(task_id=r.id, status=r.status, created_at=r.created_at, file_count=len(r.files)) for r in page],
|
||||
data=[
|
||||
TaskListItem(task_id=r.id, status=r.status, created_at=r.created_at, file_count=len(r.file_states))
|
||||
for r in page
|
||||
],
|
||||
first_id=page[0].id if page else None,
|
||||
last_id=page[-1].id if page else None,
|
||||
has_more=len(records) > limit,
|
||||
@@ -524,47 +554,47 @@ class TaskStore:
|
||||
record.changed.set()
|
||||
|
||||
async def create(
|
||||
self, backend: TaskBackend, options: UploadTaskOptions, manifest: list[TaskManifestItem], *, lease_seconds: int = 0
|
||||
self, backend: TaskBackend, options: UploadTaskOptions, inputs: list[TaskFileItem], *, lease_seconds: int = 0
|
||||
) -> _TaskRecord:
|
||||
"""先完整校验清单并固定缓存,再为各个就绪文件独立提交执行。"""
|
||||
if self._closing:
|
||||
_invalid("Server is shutting down", status_code=503, code="server_shutting_down")
|
||||
await backend.validate(options, manifest)
|
||||
await backend.validate(options, inputs)
|
||||
task_id = JobStore._new_job_id()
|
||||
record = _TaskRecord(
|
||||
task_id,
|
||||
backend,
|
||||
options,
|
||||
manifest,
|
||||
inputs,
|
||||
self._root / task_id,
|
||||
int(time.time()) + TASK_UPLOAD_TTL,
|
||||
[_TaskFileRecord(backend.fork()) for _ in manifest],
|
||||
[_TaskFileRecord(backend.fork()) for _ in inputs],
|
||||
)
|
||||
self.update_lease(record, lease_seconds)
|
||||
try:
|
||||
for index, item in enumerate(manifest):
|
||||
file = record.files[index]
|
||||
for index, item in enumerate(inputs):
|
||||
file = record.file_states[index]
|
||||
if item.url is None and item.sha256sum:
|
||||
upload = await file.backend.find_upload(item.sha256sum, item.filename)
|
||||
upload = await file.backend.find_upload(item.sha256sum, item.file_name)
|
||||
if upload is not None:
|
||||
if upload.bytes != item.bytes:
|
||||
_invalid("Declared size does not match cached bytes", code="upload_size_mismatch")
|
||||
file.upload = await file.backend.retain_upload(upload)
|
||||
self._records[task_id] = record
|
||||
self.start()
|
||||
for index, item in enumerate(manifest):
|
||||
if item.url is not None or record.files[index].upload is not None:
|
||||
for index, item in enumerate(inputs):
|
||||
if item.url is not None or record.file_states[index].upload is not None:
|
||||
self._start_file(record, index)
|
||||
record.operation = asyncio.create_task(self._monitor(record), name=f"mineru-task-{task_id}")
|
||||
return record
|
||||
except BaseException:
|
||||
for file in record.files:
|
||||
for file in record.file_states:
|
||||
file.backend.release_uploads(rollback=True)
|
||||
raise
|
||||
|
||||
def _start_file(self, record: _TaskRecord, index: int) -> None:
|
||||
"""事件循环内同步登记提交句柄,重复 PUT 不能创建第二个子作业。"""
|
||||
file = record.files[index]
|
||||
file = record.file_states[index]
|
||||
if file.operation is not None or file.status != "waiting_upload" or record.status == "canceled":
|
||||
return
|
||||
file.status = "queued"
|
||||
@@ -601,7 +631,7 @@ class TaskStore:
|
||||
return
|
||||
targets = [
|
||||
file
|
||||
for file in record.files
|
||||
for file in record.file_states
|
||||
if file.submitted
|
||||
and file.status not in {"failed", "canceled"}
|
||||
and (file.status != "completed" or file.lease_refresh_at <= time.monotonic())
|
||||
@@ -639,10 +669,10 @@ class TaskStore:
|
||||
|
||||
async def _dispatch_file(self, record: _TaskRecord, index: int) -> None:
|
||||
"""每个文件独立提交,失败或取消只清理自己的作业与源文件租约。"""
|
||||
file = record.files[index]
|
||||
file = record.file_states[index]
|
||||
try:
|
||||
execution = await file.backend.submit(
|
||||
file.execution_id, record.options, [record.manifest[index]], {0: file.upload} if file.upload is not None else {}
|
||||
file.execution_id, record.options, [record.inputs[index]], {0: file.upload} if file.upload is not None else {}
|
||||
)
|
||||
file.submitted = True
|
||||
self._apply_execution(record, file, execution)
|
||||
@@ -682,21 +712,21 @@ class TaskStore:
|
||||
await self.refresh(record)
|
||||
if record.status not in TERMINAL_TASK_STATUSES:
|
||||
await self._wait_for_change(record)
|
||||
operations = [file.operation for file in record.files if file.operation is not None]
|
||||
operations = [file.operation for file in record.file_states if file.operation is not None]
|
||||
if operations:
|
||||
await asyncio.gather(*operations, return_exceptions=True)
|
||||
while any(file.upload_active for file in record.files) or record.lease_expires_at > time.time():
|
||||
while any(file.upload_active for file in record.file_states) or record.lease_expires_at > time.time():
|
||||
record.changed.clear()
|
||||
await self._wait_for_change(record)
|
||||
finished = time.time()
|
||||
for file in record.files:
|
||||
for file in record.file_states:
|
||||
await file.backend.release_result(file.execution_id, finished)
|
||||
file.backend.release_uploads(rollback=not file.submitted)
|
||||
record.retention_epoch = finished
|
||||
except Exception:
|
||||
logger.exception("Task result cleanup failed: %s", record.id)
|
||||
finally:
|
||||
if not any(file.upload_active for file in record.files):
|
||||
if not any(file.upload_active for file in record.file_states):
|
||||
shutil.rmtree(record.directory, ignore_errors=True)
|
||||
|
||||
async def _wait_for_change(self, record: _TaskRecord) -> None:
|
||||
@@ -708,13 +738,13 @@ class TaskStore:
|
||||
|
||||
async def upload(self, record: _TaskRecord, client_file_id: str, request: Request) -> bool:
|
||||
"""在接收主体前检查固定期限,已受理流不因父任务状态变化中止。"""
|
||||
index = next((i for i, item in enumerate(record.manifest) if item.client_file_id == client_file_id), None)
|
||||
index = next((i for i, item in enumerate(record.inputs) if item.client_file_id == client_file_id), None)
|
||||
if index is None:
|
||||
_invalid("Unknown client_file_id", status_code=404, code="upload_not_found")
|
||||
expected = record.manifest[index]
|
||||
expected = record.inputs[index]
|
||||
if expected.url is not None:
|
||||
_invalid("URL inputs do not accept uploads")
|
||||
file = record.files[index]
|
||||
file = record.file_states[index]
|
||||
async with file.upload_lock:
|
||||
if record.expires_at <= time.time():
|
||||
self._expire_uploads(record)
|
||||
@@ -732,7 +762,7 @@ class TaskStore:
|
||||
except ValueError:
|
||||
_invalid("Invalid Content-Length", code="upload_size_mismatch")
|
||||
if declared != expected.bytes:
|
||||
_invalid("Content-Length does not match manifest bytes", code="upload_size_mismatch")
|
||||
_invalid("Content-Length does not match declared file size", code="upload_size_mismatch")
|
||||
file.upload_active = True
|
||||
record.directory.mkdir(parents=True, exist_ok=True)
|
||||
path = record.directory / secrets.token_hex(16)
|
||||
@@ -745,7 +775,7 @@ class TaskStore:
|
||||
upload = await _save_upload(
|
||||
request.stream(),
|
||||
path,
|
||||
expected.filename,
|
||||
expected.file_name,
|
||||
request.headers.get("content-type", "application/octet-stream"),
|
||||
expected=expected,
|
||||
canceled=was_canceled,
|
||||
@@ -775,7 +805,7 @@ class TaskStore:
|
||||
record.finished_at = JobStore._now()
|
||||
record.finished_epoch = time.time()
|
||||
record.changed.set()
|
||||
for file in record.files:
|
||||
for file in record.file_states:
|
||||
if file.status not in TERMINAL_TASK_STATUSES:
|
||||
file.status = "canceled"
|
||||
if file.operation is not None:
|
||||
@@ -811,11 +841,11 @@ def task_payload(record: _TaskRecord, request: Request) -> dict[str, Any]:
|
||||
files: list[dict[str, Any]] = []
|
||||
links: list[dict[str, Any]] = []
|
||||
starts: list[str] = []
|
||||
for item, state in zip(record.manifest, record.files):
|
||||
for item, state in zip(record.inputs, record.file_states):
|
||||
file = item.model_dump()
|
||||
upload = state.upload
|
||||
file.update(
|
||||
name=item.filename,
|
||||
name=item.file_name,
|
||||
upload_status="not_required"
|
||||
if item.url is not None
|
||||
else ("ready" if upload is not None else ("expired" if state.expired else "pending")),
|
||||
@@ -863,8 +893,8 @@ def task_payload(record: _TaskRecord, request: Request) -> dict[str, Any]:
|
||||
"output_formats": record.options.output_formats,
|
||||
"include_model_output": record.options.include_model_output,
|
||||
"progress": {
|
||||
"completed": sum(file.status == "completed" for file in record.files),
|
||||
"failed": sum(file.status == "failed" for file in record.files),
|
||||
"completed": sum(file.status == "completed" for file in record.file_states),
|
||||
"failed": sum(file.status == "failed" for file in record.file_states),
|
||||
"total": len(files),
|
||||
},
|
||||
"files": files,
|
||||
@@ -922,8 +952,8 @@ def build_task_router(factory: Callable[[Request], TaskBackend]) -> APIRouter:
|
||||
backend = factory(request)
|
||||
store: TaskStore = request.app.state.task_store
|
||||
try:
|
||||
options, manifest = await _read_submission(request)
|
||||
record = await store.create(backend, options, manifest, lease_seconds=_retention_lease(request) or 0)
|
||||
options, inputs = await _read_submission(request)
|
||||
record = await store.create(backend, options, inputs, lease_seconds=_retention_lease(request) or 0)
|
||||
except BaseException:
|
||||
backend.release_uploads(rollback=True)
|
||||
raise
|
||||
@@ -994,13 +1024,13 @@ class ApiTaskBackend:
|
||||
"""由父 Task 统一开始子作业与结果文件的保留期。"""
|
||||
self.jobs.release_task_result(task_id, finished)
|
||||
|
||||
async def validate(self, options: UploadTaskOptions, manifest: list[TaskManifestItem]) -> None:
|
||||
async def validate(self, options: UploadTaskOptions, inputs: list[TaskFileItem]) -> None:
|
||||
"""复用引擎就绪状态,提前校验显式 tier 与轻量格式能力。"""
|
||||
_require_model_preload_ready(self.request)
|
||||
if self.jobs._closing:
|
||||
_invalid("Server is shutting down", code="server_shutting_down", status_code=503)
|
||||
state = self.request.app.state
|
||||
for index, item in enumerate(manifest):
|
||||
for index, item in enumerate(inputs):
|
||||
if item.url is not None:
|
||||
try:
|
||||
_validate_source_policy(
|
||||
@@ -1008,8 +1038,8 @@ class ApiTaskBackend:
|
||||
allow_http_source=state.allow_http_source,
|
||||
)
|
||||
except ValueError as exc:
|
||||
_invalid(str(exc), param=f"manifest.{index}.url")
|
||||
options.tier = resolve_task_tier(options, manifest, set(state.tier_runtime_options), state.default_tier)
|
||||
_invalid(str(exc), param=f"files.{index}.url")
|
||||
options.tier = resolve_task_tier(options, inputs, set(state.tier_runtime_options), state.default_tier)
|
||||
|
||||
async def find_upload(self, sha256sum: str, filename: str) -> TaskUpload | None:
|
||||
"""原子查找并取得缓存租约,准备阶段不会因原文件视图到期丢失字节。"""
|
||||
@@ -1046,7 +1076,7 @@ class ApiTaskBackend:
|
||||
self._pins.clear()
|
||||
|
||||
async def submit(
|
||||
self, task_id: str, options: UploadTaskOptions, manifest: list[TaskManifestItem], uploads: dict[int, TaskUpload]
|
||||
self, task_id: str, options: UploadTaskOptions, inputs: list[TaskFileItem], uploads: dict[int, TaskUpload]
|
||||
) -> TaskExecution:
|
||||
"""提交单文件子作业,在同一次解析中生成专用产物并持有结果租约。"""
|
||||
# 接单时已持有源字节;用户删除文件视图也不能破坏尚在等待其他文件的任务。
|
||||
@@ -1060,7 +1090,7 @@ class ApiTaskBackend:
|
||||
source=_UrlSource(url=item.url) if item.url is not None else _StoredSource(file_id=uploads[index].file_id),
|
||||
page_range=item.page_range or None,
|
||||
)
|
||||
for index, item in enumerate(manifest)
|
||||
for index, item in enumerate(inputs)
|
||||
],
|
||||
tier=options.tier,
|
||||
ocr_mode=options.ocr_mode,
|
||||
@@ -1074,9 +1104,7 @@ class ApiTaskBackend:
|
||||
self.files,
|
||||
self.jobs,
|
||||
job_id=task_id,
|
||||
input_specs={
|
||||
index: _JobInputSpec(item.filename, item.bytes, item.sha256sum) for index, item in enumerate(manifest)
|
||||
},
|
||||
input_specs={index: _JobInputSpec(item.file_name, item.bytes, item.sha256sum) for index, item in enumerate(inputs)},
|
||||
)
|
||||
return await self.get(task_id)
|
||||
|
||||
@@ -1105,7 +1133,8 @@ __all__ = [
|
||||
"CreateTaskRequest",
|
||||
"TaskBackend",
|
||||
"TaskExecution",
|
||||
"TaskManifestItem",
|
||||
"TaskFileItem",
|
||||
"TaskFileInput",
|
||||
"TaskListItem",
|
||||
"TaskListResponse",
|
||||
"TaskStatusResponse",
|
||||
|
||||
@@ -47,9 +47,9 @@ class InputFile:
|
||||
size: int
|
||||
sha256sum: str | None
|
||||
|
||||
def manifest(self) -> dict[str, Any]:
|
||||
def task_file(self) -> dict[str, Any]:
|
||||
"""生成 JSON 清单条目;关闭 hash 时完全省略该可选字段。"""
|
||||
item: dict[str, Any] = {"client_file_id": self.client_file_id, "filename": self.path.name, "bytes": self.size}
|
||||
item: dict[str, Any] = {"client_file_id": self.client_file_id, "file_name": self.path.name, "bytes": self.size}
|
||||
if self.sha256sum is not None:
|
||||
item["sha256sum"] = self.sha256sum
|
||||
return item
|
||||
@@ -254,8 +254,8 @@ class TaskClient:
|
||||
raise ClientError("Invalid or duplicate client_file_id")
|
||||
seen.add(client_id)
|
||||
local = self.by_id[client_id]
|
||||
if item.get("filename") != local.path.name or item.get("bytes") != local.size:
|
||||
raise ClientError("Task manifest differs from local inputs; use the original file list and order")
|
||||
if item.get("file_name") != local.path.name or item.get("bytes") != local.size:
|
||||
raise ClientError("Task files differ from local inputs; use the original file list and order")
|
||||
if local.sha256sum is not None and item.get("sha256sum") not in {None, local.sha256sum}:
|
||||
raise ClientError("Task hash differs from the local file")
|
||||
if item.get("upload_status") not in {"pending", "ready", "expired"}:
|
||||
@@ -305,7 +305,7 @@ class TaskClient:
|
||||
if not formats or any(value not in {"default", "docx", "html", "latex"} for value in formats):
|
||||
raise ClientError("OUTPUT_FORMATS must contain default/docx/html/latex")
|
||||
body: dict[str, Any] = {
|
||||
"manifest": [file.manifest() for file in self.files],
|
||||
"files": [file.task_file() for file in self.files],
|
||||
"ocr_mode": os.environ.get("OCR_MODE", "auto"),
|
||||
"page_range": os.environ.get("PAGE_RANGE", ""),
|
||||
"output_formats": list(dict.fromkeys(formats)),
|
||||
|
||||
@@ -39,10 +39,10 @@ def submit_task(client: TestClient, path: Path, **options: Any) -> dict[str, Any
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"manifest": [
|
||||
"files": [
|
||||
{
|
||||
"client_file_id": "file",
|
||||
"filename": path.name,
|
||||
"file_name": path.name,
|
||||
"bytes": len(data),
|
||||
"sha256sum": hashlib.sha256(data).hexdigest(),
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ def test_api_engine_failure_drains_running_and_queued_jobs(tmp_path: Path, monke
|
||||
*(
|
||||
app.state.job_store.wait_for_terminal(file.execution_id, 2)
|
||||
for record in app.state.task_store._records.values()
|
||||
for file in record.files
|
||||
for file in record.file_states
|
||||
)
|
||||
)
|
||||
await asyncio.gather(*(record.operation for record in app.state.task_store._records.values()))
|
||||
@@ -93,7 +93,7 @@ def test_api_engine_failure_drains_running_and_queued_jobs(tmp_path: Path, monke
|
||||
response = getattr(client, method)(
|
||||
path,
|
||||
**(
|
||||
{"json": {"tier": "flash", "manifest": [{"client_file_id": "file", "filename": "new.pdf", "bytes": 3}]}}
|
||||
{"json": {"tier": "flash", "files": [{"client_file_id": "file", "file_name": "new.pdf", "bytes": 3}]}}
|
||||
if method == "post"
|
||||
else {}
|
||||
),
|
||||
|
||||
@@ -114,7 +114,7 @@ class Upstream:
|
||||
if self.create_failure == "auth":
|
||||
return httpx.Response(401, json={"error": {"code": "invalid_api_key", "message": "Bad upstream key"}})
|
||||
body = json.loads(await request.aread())
|
||||
assert len(body["manifest"]) == 1
|
||||
assert len(body["files"]) == 1
|
||||
task_id = f"task_{len(self.tasks) + 1}"
|
||||
self.tasks[task_id] = {
|
||||
"options": body,
|
||||
@@ -135,7 +135,7 @@ class Upstream:
|
||||
self.uploads += 1
|
||||
assert request.headers["x-upload-token"] == "storage-token"
|
||||
content = await request.aread()
|
||||
assert len(content) == task["options"]["manifest"][0]["bytes"]
|
||||
assert len(content) == task["options"]["files"][0]["bytes"]
|
||||
if self.upload_failures:
|
||||
self.upload_failures -= 1
|
||||
return httpx.Response(503)
|
||||
|
||||
@@ -227,7 +227,7 @@ def test_html_parse_server_flash_only_admits_extensionless_url(
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={"manifest": [{"client_file_id": "file", "filename": "article.html", "url": "https://example.com/article"}]},
|
||||
json={"files": [{"client_file_id": "file", "file_name": "article.html", "url": "https://example.com/article"}]},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
|
||||
@@ -1008,7 +1008,7 @@ def test_api_server_basic_rejects_advanced_request(tmp_path: Path, monkeypatch:
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"manifest": [{"client_file_id": "file", "filename": source.name, "bytes": source.stat().st_size}],
|
||||
"files": [{"client_file_id": "file", "file_name": source.name, "bytes": source.stat().st_size}],
|
||||
"tier": "advanced",
|
||||
},
|
||||
)
|
||||
@@ -1032,7 +1032,7 @@ def test_api_server_no_advanced_rejects_advanced_request(tmp_path: Path, monkeyp
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"manifest": [{"client_file_id": "file", "filename": source.name, "bytes": source.stat().st_size}],
|
||||
"files": [{"client_file_id": "file", "file_name": source.name, "bytes": source.stat().st_size}],
|
||||
"tier": "advanced",
|
||||
},
|
||||
)
|
||||
@@ -1054,7 +1054,7 @@ def test_api_server_no_flash_rejects_flash_request(tmp_path: Path, monkeypatch:
|
||||
response = TestClient(app).post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"manifest": [{"client_file_id": "file", "filename": source.name, "bytes": source.stat().st_size}],
|
||||
"files": [{"client_file_id": "file", "file_name": source.name, "bytes": source.stat().st_size}],
|
||||
"tier": "flash",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -61,10 +61,10 @@ def test_api_parser_serializes_request_ocr_mode(
|
||||
parser.parse(source, page_range="1")
|
||||
|
||||
expected: dict[str, Any] = {
|
||||
"manifest": [
|
||||
"files": [
|
||||
{
|
||||
"client_file_id": "file",
|
||||
"filename": source.name,
|
||||
"file_name": source.name,
|
||||
"bytes": source.stat().st_size,
|
||||
"sha256sum": api_client._sha256_file(source),
|
||||
"page_range": "1",
|
||||
@@ -94,7 +94,7 @@ def test_api_server_rejects_invalid_request_ocr_mode(tmp_path: Path, value: obje
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"manifest": [{"client_file_id": "file", "filename": "demo.pdf", "bytes": 9}],
|
||||
"files": [{"client_file_id": "file", "file_name": "demo.pdf", "bytes": 9}],
|
||||
"ocr_mode": value,
|
||||
},
|
||||
)
|
||||
@@ -106,7 +106,7 @@ def test_api_server_rejects_invalid_request_ocr_mode(tmp_path: Path, value: obje
|
||||
|
||||
def test_api_server_ocr_defaults_to_request_auto_without_startup_configuration(tmp_path: Path) -> None:
|
||||
"""请求缺省值固定为 auto,应用生命周期不保存全局 OCR 模式。"""
|
||||
request = CreateTaskRequest.model_validate({"manifest": [{"client_file_id": "file", "filename": "demo.pdf", "bytes": 9}]})
|
||||
request = CreateTaskRequest.model_validate({"files": [{"client_file_id": "file", "file_name": "demo.pdf", "bytes": 9}]})
|
||||
assert request.ocr_mode == "auto"
|
||||
app = api_server.create_app(upload_dir=str(tmp_path), tier="flash")
|
||||
assert not hasattr(app.state, "ocr_mode")
|
||||
|
||||
@@ -176,16 +176,16 @@ def test_api_rejects_bad_ranges_before_creating_jobs(tmp_path: Path) -> None:
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"tier": "flash",
|
||||
"manifest": [
|
||||
{"client_file_id": "first", "filename": "a.pdf", "bytes": 1},
|
||||
{"client_file_id": "second", "filename": "b.pdf", "bytes": 1, "page_range": raw},
|
||||
"files": [
|
||||
{"client_file_id": "first", "file_name": "a.pdf", "bytes": 1},
|
||||
{"client_file_id": "second", "file_name": "b.pdf", "bytes": 1, "page_range": raw},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
error = response.json()["error"]
|
||||
assert error["code"] == "page_range_invalid"
|
||||
assert error["param"] == "manifest.1.page_range"
|
||||
assert error["param"] == "files.1.page_range"
|
||||
schema = client.get("/openapi.json").json()
|
||||
assert "r1" in schema["components"]["schemas"]["TaskFileStatus"]["properties"]["page_range"]["description"]
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ def test_hash_preflight_rejection_releases_request_lease(tmp_path: Path, monkeyp
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"tier": "flash",
|
||||
"manifest": [{"client_file_id": "f1", "filename": "input.pdf", "bytes": 3, "sha256sum": sha}],
|
||||
"files": [{"client_file_id": "f1", "file_name": "input.pdf", "bytes": 3, "sha256sum": sha}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -18,7 +18,7 @@ from mineru.parser import api_server
|
||||
from mineru.kit.router import RouterSettings, create_app
|
||||
from mineru.kit.router.workers import JobReservation
|
||||
|
||||
BODY = {"tier": "standard", "manifest": [{"client_file_id": "file", "filename": "source.pdf", "bytes": 5}]}
|
||||
BODY = {"tier": "standard", "files": [{"client_file_id": "file", "file_name": "source.pdf", "bytes": 5}]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("formats", [["unknown"], "markdown", None, 1, {}, [{}], [1]])
|
||||
@@ -77,10 +77,10 @@ def test_task_auth_preflight_uses_a_capable_worker_without_reservation() -> None
|
||||
)
|
||||
form = {
|
||||
"tier": "standard",
|
||||
"manifest": [
|
||||
"files": [
|
||||
{
|
||||
"client_file_id": "f1",
|
||||
"filename": "source.pdf",
|
||||
"file_name": "source.pdf",
|
||||
"bytes": 3,
|
||||
}
|
||||
],
|
||||
@@ -127,7 +127,7 @@ def test_cached_tasks_still_balance(tmp_path: Path, monkeypatch: pytest.MonkeyPa
|
||||
assert task["uploads"] == []
|
||||
tasks.append(task)
|
||||
deadline = time.monotonic() + 5
|
||||
while not app.state.task_store._records[task["task_id"]].files[0].submitted:
|
||||
while not app.state.task_store._records[task["task_id"]].file_states[0].submitted:
|
||||
assert time.monotonic() < deadline
|
||||
time.sleep(0.01)
|
||||
assert [worker.active_jobs for worker in app.state.worker_pool.workers] == [5, 5]
|
||||
|
||||
@@ -84,10 +84,10 @@ async def _submit(client: httpx.AsyncClient, *, filename: str = "input.html", ur
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"tier": "flash",
|
||||
"manifest": [
|
||||
"files": [
|
||||
{
|
||||
"client_file_id": "file",
|
||||
"filename": filename,
|
||||
"file_name": filename,
|
||||
"bytes": len(HTML),
|
||||
"sha256sum": hashlib.sha256(HTML).hexdigest(),
|
||||
**({"url": url} if url is not None else {}),
|
||||
@@ -171,7 +171,7 @@ def test_cascade_can_cancel_while_waiting_for_source_file(tmp_path: Path) -> Non
|
||||
await outer.state.task_store._records[task["task_id"]].operation
|
||||
for record in inner.state.task_store._records.values():
|
||||
# 下游仍有结果租约,取消清理只需等待实际子文件退出。
|
||||
await asyncio.gather(*(file.operation for file in record.files if file.operation is not None))
|
||||
await asyncio.gather(*(file.operation for file in record.file_states if file.operation is not None))
|
||||
assert not worker.state.job_store._jobs
|
||||
for app in (outer, inner):
|
||||
assert all(item.active_jobs == 0 for item in app.state.worker_pool.workers)
|
||||
|
||||
+140
-34
@@ -60,7 +60,7 @@ def _url_manifest(**metadata: Any) -> dict[str, Any]:
|
||||
"""构造与下载地址名称无关的逻辑文件条目,默认不声明大小或 hash。"""
|
||||
return {
|
||||
"client_file_id": "remote",
|
||||
"filename": "logical.html",
|
||||
"file_name": "logical.html",
|
||||
"url": "https://source.example/opaque?signature=a%2Fb&part=1",
|
||||
**metadata,
|
||||
}
|
||||
@@ -120,13 +120,13 @@ def _submit(
|
||||
manifest = [
|
||||
{
|
||||
"client_file_id": f"f{index + 1}",
|
||||
"filename": name,
|
||||
"file_name": name,
|
||||
"bytes": len(data),
|
||||
**({"sha256sum": hashlib.sha256(data).hexdigest()} if hashes else {}),
|
||||
}
|
||||
for index, (name, data) in enumerate(inputs)
|
||||
]
|
||||
response = client.post("/v1/tasks", json={"manifest": manifest, "tier": "flash", **options}, headers=headers)
|
||||
response = client.post("/v1/tasks", json={"files": manifest, "tier": "flash", **options}, headers=headers)
|
||||
assert response.status_code == 202, response.text
|
||||
return response.json()
|
||||
|
||||
@@ -274,7 +274,7 @@ def test_no_hash_still_deduplicates_source_storage(service: tuple[TestClient, Fa
|
||||
_upload(client, second, [("second.html", HTML)])
|
||||
assert _terminal(client, second)["status"] == "completed"
|
||||
records = app.state.task_store._records
|
||||
assert records[first["task_id"]].files[0].upload.path == records[second["task_id"]].files[0].upload.path
|
||||
assert records[first["task_id"]].file_states[0].upload.path == records[second["task_id"]].file_states[0].upload.path
|
||||
|
||||
|
||||
def test_waiting_upload_retains_deleted_source_views(service: tuple[TestClient, FastAPI]) -> None:
|
||||
@@ -362,7 +362,7 @@ def test_cancel_and_expire_waiting_upload_release_inputs(service: tuple[TestClie
|
||||
|
||||
client.portal.call(wait_cleanup)
|
||||
assert not record.directory.exists()
|
||||
assert all(file.backend._owned_files == [] for file in record.files)
|
||||
assert all(file.backend._owned_files == [] for file in record.file_states)
|
||||
|
||||
|
||||
def test_cancel_running_task_releases_execution_slot(
|
||||
@@ -465,7 +465,7 @@ def test_partial_and_packaging_failure_have_honest_results(
|
||||
def test_input_validation_and_openapi(service: tuple[TestClient, FastAPI]) -> None:
|
||||
"""请求与 OpenAPI 都使用严格 JSON 数组,旧格式与额外字段在接单前拒绝。"""
|
||||
client, app = service
|
||||
valid = {"client_file_id": "f1", "filename": "a.html", "bytes": len(HTML)}
|
||||
valid = {"client_file_id": "f1", "file_name": "a.html", "bytes": len(HTML)}
|
||||
for manifest in (
|
||||
[],
|
||||
[valid] * 2,
|
||||
@@ -475,12 +475,20 @@ def test_input_validation_and_openapi(service: tuple[TestClient, FastAPI]) -> No
|
||||
[{**valid, "page_range": "invalid"}],
|
||||
json.dumps([valid]),
|
||||
):
|
||||
assert client.post("/v1/tasks", json={"manifest": manifest}).status_code == 400
|
||||
assert client.post("/v1/tasks", json={"files": manifest}).status_code == 400
|
||||
for formats in ([], "default", ["markdown"], ["zip"], ["pdf"], [None]):
|
||||
assert client.post("/v1/tasks", json={"manifest": [valid], "output_formats": formats}).status_code == 400
|
||||
assert client.post("/v1/tasks?tier=flash", json={"manifest": [valid]}).status_code == 400
|
||||
assert client.post("/v1/tasks", json={"manifest": [valid], "wait_timeout": 300}).status_code == 400
|
||||
assert client.post("/v1/tasks", json={"files": [valid], "output_formats": formats}).status_code == 400
|
||||
assert client.post("/v1/tasks?tier=flash", json={"files": [valid]}).status_code == 400
|
||||
assert client.post("/v1/tasks", json={"files": [valid], "wait_timeout": 300}).status_code == 400
|
||||
assert client.post("/v1/tasks", json={"files": []}).status_code == 400
|
||||
for body in (
|
||||
{"manifest": [valid]},
|
||||
{"files": [valid], "manifest": [valid]},
|
||||
{"files": [{"filename": "a.html", "bytes": len(HTML)}]},
|
||||
{"files": [{**valid, "filename": "a.html"}]},
|
||||
{"files": [{"url": "https://source.example/a.html"}]},
|
||||
):
|
||||
assert client.post("/v1/tasks", json=body).status_code == 400
|
||||
assert client.post("/v1/tasks", content=b"{", headers={"content-type": "application/json"}).status_code == 400
|
||||
assert _job_count(app) == 0
|
||||
spec = client.get("/openapi.json").json()
|
||||
@@ -488,7 +496,23 @@ def test_input_validation_and_openapi(service: tuple[TestClient, FastAPI]) -> No
|
||||
creation = spec["paths"]["/v1/tasks"]["post"]
|
||||
assert not creation.get("parameters")
|
||||
assert set(creation["requestBody"]["content"]) == {"application/json"}
|
||||
assert creation["requestBody"]["content"]["application/json"]["schema"]["properties"]["manifest"]["type"] == "array"
|
||||
request_schema = creation["requestBody"]["content"]["application/json"]["schema"]
|
||||
assert request_schema["required"] == ["files"]
|
||||
assert "manifest" not in request_schema["properties"]
|
||||
files_schema = request_schema["properties"]["files"]
|
||||
assert files_schema["type"] == "array"
|
||||
assert files_schema["minItems"] == 1 and files_schema["maxItems"] == 100
|
||||
assert "file_name" in files_schema["items"]["required"]
|
||||
assert "client_file_id" not in files_schema["items"]["required"]
|
||||
assert "filename" not in files_schema["items"]["properties"]
|
||||
for model in ("TaskFileStatus", "TaskUploadLink"):
|
||||
id_schema = spec["components"]["schemas"][model]
|
||||
assert "client_file_id" in id_schema["required"]
|
||||
assert id_schema["properties"]["client_file_id"]["type"] == "string"
|
||||
if hasattr(app.state, "job_store"):
|
||||
metadata_schema = spec["components"]["schemas"]["FileObjectModel"]
|
||||
assert "file_name" in metadata_schema["required"]
|
||||
assert "filename" not in metadata_schema["properties"]
|
||||
assert "result_url" not in spec["components"]["schemas"]["TaskStatusResponse"]["properties"]
|
||||
assert "result_url" in spec["components"]["schemas"]["TaskFileStatus"]["properties"]
|
||||
assert "output_files" not in spec["components"]["schemas"]["TaskFileStatus"]["properties"]
|
||||
@@ -497,6 +521,84 @@ def test_input_validation_and_openapi(service: tuple[TestClient, FastAPI]) -> No
|
||||
assert limits["max_files_per_job"] == 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("identities", "expected_ids"),
|
||||
[
|
||||
([{}, {}], ["f1", "f2"]),
|
||||
([{"client_file_id": None}, {}], ["f1", "f2"]),
|
||||
([{}, {"client_file_id": "f2"}, {}], ["f1", "f2", "f3"]),
|
||||
([{}, {"client_file_id": "f1"}, {}], ["f2", "f1", "f3"]),
|
||||
([{"client_file_id": "custom"}, {"client_file_id": "other"}], ["custom", "other"]),
|
||||
],
|
||||
)
|
||||
def test_optional_file_ids_survive_upload_and_router_forwarding(
|
||||
service: tuple[TestClient, FastAPI], identities: list[dict[str, str | None]], expected_ids: list[str]
|
||||
) -> None:
|
||||
"""缺省标识避让后续显式项,同名输入在重复查询、上传重试和 Router 转发后仍保持关联。"""
|
||||
client, app = service
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={"files": [{"file_name": "same.html", "bytes": len(HTML), **identity} for identity in identities]},
|
||||
)
|
||||
assert response.status_code == 202, response.text
|
||||
task = response.json()
|
||||
for _ in range(2):
|
||||
assert [item["client_file_id"] for item in task["files"]] == expected_ids
|
||||
assert [item["client_file_id"] for item in task["uploads"]] == expected_ids
|
||||
assert all(item["file_name"] == "same.html" and "filename" not in item for item in task["files"])
|
||||
task = client.get(task["status_url"]).json()
|
||||
for link in task["uploads"]:
|
||||
for _ in range(2):
|
||||
assert client.put(link["upload_url"], content=HTML, headers=link["upload_headers"]).status_code == 200
|
||||
completed = _terminal(client, task)
|
||||
assert completed["status"] == "completed", completed
|
||||
assert [item["client_file_id"] for item in completed["files"]] == expected_ids
|
||||
assert _job_count(app) == len(expected_ids)
|
||||
for index in range(len(expected_ids)):
|
||||
assert b"Preserved content" in _archive(client, completed, index)["markdown.md"]
|
||||
if hasattr(app.state, "test_transport"):
|
||||
forwarded = [
|
||||
item.client_file_id
|
||||
for worker in _execution_apps(app)
|
||||
for record in worker.state.task_store._records.values()
|
||||
for item in record.inputs
|
||||
]
|
||||
assert sorted(forwarded) == sorted(expected_ids)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("client_file_id", ["", "with space", "bad/id", "x" * 65, 123, False])
|
||||
def test_invalid_explicit_file_ids_fail_before_creation(service: tuple[TestClient, FastAPI], client_file_id: object) -> None:
|
||||
"""可选标识不放宽显式值校验,错误仍定位到新 files 字段且不创建任务。"""
|
||||
client, app = service
|
||||
response = client.post(
|
||||
"/v1/tasks", json={"files": [{"file_name": "a.html", "bytes": len(HTML), "client_file_id": client_file_id}]}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"]["param"] == "files.0.client_file_id"
|
||||
assert not app.state.task_store._records and _job_count(app) == 0
|
||||
|
||||
|
||||
def test_files_metadata_and_attachment_keep_logical_names(service: tuple[TestClient, FastAPI]) -> None:
|
||||
"""Tasks 和 Files 只返回新文件名字段,源文件与结果附件仍使用各自逻辑名称。"""
|
||||
client, _ = service
|
||||
inputs = [("report.v2.html", HTML)]
|
||||
task = _submit(client, inputs)
|
||||
_upload(client, task, inputs)
|
||||
completed = _terminal(client, task)
|
||||
item = completed["files"][0]
|
||||
assert item["file_name"] == item["name"] == "report.v2.html" and "filename" not in item
|
||||
source = client.get(f"/v1/files/{item['file_id']}").json()
|
||||
result = client.get(item["result_url"].removesuffix("/content")).json()
|
||||
assert source["file_name"] == "report.v2.html" and "filename" not in source
|
||||
assert result["file_name"] == "report.v2.zip" and "filename" not in result
|
||||
listed = client.get("/v1/files").json()["data"]
|
||||
assert {entry["file_name"] for entry in listed} == {"report.v2.html", "report.v2.zip"}
|
||||
assert all("filename" not in entry for entry in listed)
|
||||
download = client.get(item["result_url"])
|
||||
assert download.status_code == 200
|
||||
assert download.headers["content-disposition"] == 'attachment; filename="report.v2.zip"'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata",
|
||||
[
|
||||
@@ -506,6 +608,7 @@ def test_input_validation_and_openapi(service: tuple[TestClient, FastAPI]) -> No
|
||||
{"sha256sum": hashlib.sha256(HTML).hexdigest()},
|
||||
{"bytes": len(HTML), "sha256sum": hashlib.sha256(HTML).hexdigest()},
|
||||
{"url": "https://source.example/opaque?signature=" + "a" * 3000},
|
||||
{"client_file_id": None},
|
||||
],
|
||||
)
|
||||
def test_url_inputs_download_in_worker_and_report_actual_metadata(
|
||||
@@ -516,23 +619,26 @@ def test_url_inputs_download_in_worker_and_report_actual_metadata(
|
||||
item = _url_manifest(**metadata)
|
||||
if hasattr(app.state, "worker_pool"):
|
||||
app.state.worker_pool.workers[0].features["sources"] = ["file_id"]
|
||||
response = client.post("/v1/tasks", json={"manifest": [item]})
|
||||
response = client.post("/v1/tasks", json={"files": [item]})
|
||||
assert response.status_code == 202, response.text
|
||||
task = response.json()
|
||||
expected_id = item["client_file_id"] or "f1"
|
||||
assert task["files"][0]["client_file_id"] == expected_id
|
||||
assert task["status"] == "queued" and task["uploads"] == []
|
||||
assert task["files"][0]["bytes"] == metadata.get("bytes")
|
||||
assert task["files"][0]["upload_status"] == "not_required"
|
||||
result = _terminal(client, task)
|
||||
assert result["status"] == "completed"
|
||||
file = result["files"][0]
|
||||
assert file["filename"] == file["name"] == "logical.html"
|
||||
assert file["client_file_id"] == expected_id
|
||||
assert file["file_name"] == file["name"] == "logical.html"
|
||||
assert file["bytes"] == len(HTML) and file["sha256sum"] == hashlib.sha256(HTML).hexdigest()
|
||||
assert file["upload_status"] == "not_required" and file["file_id"] is None
|
||||
assert file["url"] == item["url"]
|
||||
assert b"Preserved content" in _archive(client, result)["markdown.md"]
|
||||
assert len(url_downloads) == 1 and str(url_downloads[0].url) == item["url"]
|
||||
assert url_downloads[0].method == "GET" and "authorization" not in url_downloads[0].headers
|
||||
assert client.put(f"/v1/tasks/{task['task_id']}/uploads/remote", content=HTML).status_code == 400
|
||||
assert client.put(f"/v1/tasks/{task['task_id']}/uploads/{expected_id}", content=HTML).status_code == 400
|
||||
if hasattr(app.state, "test_transport"):
|
||||
assert not any(method == "PUT" for _, method, _ in app.state.test_transport.calls)
|
||||
assert all(host == "worker-b" for host, method, path in app.state.test_transport.calls if path == "/v1/tasks")
|
||||
@@ -542,13 +648,13 @@ def test_url_manifest_validation(service: tuple[TestClient, FastAPI], url_downlo
|
||||
"""大小只对 URL 可选,非法 URL、非正整数字节数及混合直传在接单前拒绝。"""
|
||||
client, app = service
|
||||
invalid = [
|
||||
{"client_file_id": "f1", "filename": "a.html"},
|
||||
{"client_file_id": "f1", "filename": "a.html", "bytes": None},
|
||||
{"client_file_id": "f1", "file_name": "a.html"},
|
||||
{"client_file_id": "f1", "file_name": "a.html", "bytes": None},
|
||||
*[_url_manifest(url=value) for value in ("", "file:///tmp/a.html", "https://", "ftp://source/a.html")],
|
||||
*[_url_manifest(bytes=value) for value in (0, -1, True, "10", 10.5)],
|
||||
]
|
||||
for item in invalid:
|
||||
response = client.post("/v1/tasks", json={"manifest": [item]})
|
||||
response = client.post("/v1/tasks", json={"files": [item]})
|
||||
assert response.status_code == 400, response.text
|
||||
response = client.post("/v1/tasks", files={"files": ("a.html", HTML)})
|
||||
assert response.status_code == 415
|
||||
@@ -563,11 +669,11 @@ def test_mixed_url_upload_partial_and_no_early_reservation(
|
||||
"""URL 文件不等待客户端上传,错误和完成结果都按清单顺序独立返回。"""
|
||||
client, app = service
|
||||
manifest = [
|
||||
_url_manifest(filename="same.html"),
|
||||
{"client_file_id": "local", "filename": "same.html", "bytes": len(SECOND)},
|
||||
_url_manifest(file_name="same.html"),
|
||||
{"client_file_id": "local", "file_name": "same.html", "bytes": len(SECOND)},
|
||||
_url_manifest(client_file_id="bad", url="https://source.example/missing"),
|
||||
]
|
||||
response = client.post("/v1/tasks", json={"manifest": manifest})
|
||||
response = client.post("/v1/tasks", json={"files": manifest})
|
||||
assert response.status_code == 202, response.text
|
||||
task = response.json()
|
||||
assert task["status"] == "queued"
|
||||
@@ -599,7 +705,7 @@ def test_url_download_failures_are_per_file(
|
||||
) -> None:
|
||||
"""下载及完整性错误进入文件错误;全失败不生成结果链接或实际元数据。"""
|
||||
client, _ = service
|
||||
response = client.post("/v1/tasks", json={"manifest": [_url_manifest(**metadata)]})
|
||||
response = client.post("/v1/tasks", json={"files": [_url_manifest(**metadata)]})
|
||||
assert response.status_code == 202
|
||||
result = _terminal(client, response.json())
|
||||
assert result["status"] == "failed" and result["files"][0]["result_url"] is None
|
||||
@@ -616,7 +722,7 @@ def test_url_hash_does_not_skip_download(service: tuple[TestClient, FastAPI], ur
|
||||
_upload(client, cached, inputs)
|
||||
assert _terminal(client, cached)["status"] == "completed"
|
||||
for _ in range(2):
|
||||
response = client.post("/v1/tasks", json={"manifest": [_url_manifest(sha256sum=hashlib.sha256(HTML).hexdigest())]})
|
||||
response = client.post("/v1/tasks", json={"files": [_url_manifest(sha256sum=hashlib.sha256(HTML).hexdigest())]})
|
||||
assert response.status_code == 202
|
||||
assert _terminal(client, response.json())["status"] == "completed"
|
||||
assert len(url_downloads) == 2
|
||||
@@ -628,7 +734,7 @@ def test_url_download_obeys_worker_policy_and_limit(
|
||||
"""HTTP 开关和 URL 下载预算继续由真正执行下载的服务控制。"""
|
||||
client, app = service
|
||||
item = _url_manifest(url="http://source.example/opaque")
|
||||
response = client.post("/v1/tasks", json={"manifest": [item]})
|
||||
response = client.post("/v1/tasks", json={"files": [item]})
|
||||
if hasattr(app.state, "worker_pool"):
|
||||
assert response.status_code == 202
|
||||
assert _terminal(client, response.json())["status"] == "failed"
|
||||
@@ -639,14 +745,14 @@ def test_url_download_obeys_worker_policy_and_limit(
|
||||
worker.state.allow_http_source = True
|
||||
worker.state.max_url_bytes = len(HTML) - 1
|
||||
worker.state.url_timeout = 7
|
||||
response = client.post("/v1/tasks", json={"manifest": [item]})
|
||||
response = client.post("/v1/tasks", json={"files": [item]})
|
||||
result = _terminal(client, response.json())
|
||||
assert result["status"] == "failed"
|
||||
assert "max_url_bytes" in result["files"][0]["error"]["message"]
|
||||
assert url_downloads[0].extensions["timeout"]["read"] == 7
|
||||
for worker in _execution_apps(app):
|
||||
worker.state.max_url_bytes = len(HTML)
|
||||
response = client.post("/v1/tasks", json={"manifest": [item]})
|
||||
response = client.post("/v1/tasks", json={"files": [item]})
|
||||
assert _terminal(client, response.json())["status"] == "completed"
|
||||
|
||||
|
||||
@@ -664,7 +770,7 @@ def test_url_html_preserves_source_context(
|
||||
return await original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(api_server, "parse_async", inspect_context)
|
||||
response = client.post("/v1/tasks", json={"manifest": [_url_manifest()]})
|
||||
response = client.post("/v1/tasks", json={"files": [_url_manifest()]})
|
||||
assert _terminal(client, response.json())["status"] == "completed"
|
||||
assert len(contexts) == 1 and len(url_downloads) == 1
|
||||
assert contexts[0].source_uri == _url_manifest()["url"]
|
||||
@@ -680,7 +786,7 @@ def test_url_logical_filename_drives_flash_fallback(
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"manifest": [_url_manifest(url="https://source.example/document.pdf")],
|
||||
"files": [_url_manifest(url="https://source.example/document.pdf")],
|
||||
"tier": "standard",
|
||||
},
|
||||
)
|
||||
@@ -724,7 +830,7 @@ def test_cancel_url_download_closes_stream_and_releases_worker(
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(respond), **kwargs)
|
||||
|
||||
monkeypatch.setattr(api_server, "httpx", SimpleNamespace(AsyncClient=download_client))
|
||||
response = client.post("/v1/tasks", json={"manifest": [_url_manifest()]})
|
||||
response = client.post("/v1/tasks", json={"files": [_url_manifest()]})
|
||||
assert response.status_code == 202
|
||||
task = response.json()
|
||||
assert started.wait(5)
|
||||
@@ -773,8 +879,8 @@ def test_task_links_respect_proxy_root(tmp_path: Path, root: str) -> None:
|
||||
"""状态、上传与结果链接全部携带部署前缀,客户端不用重建任何地址。"""
|
||||
app = api_server.create_app(upload_dir=str(tmp_path), tier="flash", api_key="secret")
|
||||
with TestClient(app, root_path=root, headers={"Authorization": "Bearer secret"}) as client:
|
||||
manifest = [{"client_file_id": "f1", "filename": "a.html", "bytes": len(HTML)}]
|
||||
response = client.post(root + "/v1/tasks", json={"manifest": manifest, "tier": "flash"})
|
||||
manifest = [{"client_file_id": "f1", "file_name": "a.html", "bytes": len(HTML)}]
|
||||
response = client.post(root + "/v1/tasks", json={"files": manifest, "tier": "flash"})
|
||||
assert response.status_code == 202
|
||||
task = response.json()
|
||||
assert task["status_url"].startswith("http://testserver" + root + "/v1/tasks/")
|
||||
@@ -803,7 +909,7 @@ def test_inflight_upload_may_finish_after_deadline(service: tuple[TestClient, Fa
|
||||
async def body() -> AsyncIterator[bytes]:
|
||||
"""第一块已经被接收后模拟到期,仍在传输的文件不能被清理。"""
|
||||
yield HTML[:10]
|
||||
assert record.files[0].upload_active
|
||||
assert record.file_states[0].upload_active
|
||||
record.expires_at = int(time.time()) - 1
|
||||
state = (await transport.get(task["status_url"])).json()
|
||||
assert state["files"][0]["status"] == "waiting_upload"
|
||||
@@ -899,7 +1005,7 @@ def test_router_batch_uses_independent_workers_and_task_capabilities(tmp_path: P
|
||||
for worker in app.state.worker_pool.workers:
|
||||
worker.features.pop("task_output_formats")
|
||||
response = client.post(
|
||||
"/v1/tasks", json={"manifest": [{"client_file_id": "f1", "filename": "a.html", "bytes": len(HTML)}]}
|
||||
"/v1/tasks", json={"files": [{"client_file_id": "f1", "file_name": "a.html", "bytes": len(HTML)}]}
|
||||
)
|
||||
assert response.status_code == 503
|
||||
assert all(worker.active_jobs == 0 for worker in app.state.worker_pool.workers)
|
||||
@@ -1061,7 +1167,7 @@ def test_model_output_option_reaches_worker_and_result(
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"manifest": [{"client_file_id": "file", "filename": "input.html", "bytes": len(HTML)}],
|
||||
"files": [{"client_file_id": "file", "file_name": "input.html", "bytes": len(HTML)}],
|
||||
"tier": "flash",
|
||||
"include_model_output": enabled,
|
||||
},
|
||||
@@ -1077,7 +1183,7 @@ def test_model_output_option_reaches_worker_and_result(
|
||||
rejected = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"manifest": [{"client_file_id": "file", "filename": "input.html", "bytes": len(HTML)}],
|
||||
"files": [{"client_file_id": "file", "file_name": "input.html", "bytes": len(HTML)}],
|
||||
"output_formats": ["html"],
|
||||
"include_model_output": True,
|
||||
},
|
||||
|
||||
@@ -134,13 +134,13 @@ def _submit(client: httpx.Client, files: list[tuple[str, bytes]], *, hashes: boo
|
||||
manifest = [
|
||||
{
|
||||
"client_file_id": f"f{index + 1}",
|
||||
"filename": name,
|
||||
"file_name": name,
|
||||
"bytes": len(data),
|
||||
**({"sha256sum": hashlib.sha256(data).hexdigest()} if hashes else {}),
|
||||
}
|
||||
for index, (name, data) in enumerate(files)
|
||||
]
|
||||
response = client.post("/v1/tasks", json={"manifest": manifest, "tier": "flash", "ocr_mode": "txt", **options})
|
||||
response = client.post("/v1/tasks", json={"files": manifest, "tier": "flash", "ocr_mode": "txt", **options})
|
||||
assert response.status_code == 202, response.text
|
||||
return response.json()
|
||||
|
||||
@@ -165,6 +165,57 @@ def _members(data: bytes) -> dict[str, bytes]:
|
||||
return {name: archive.read(name) for name in archive.namelist()}
|
||||
|
||||
|
||||
def test_pdf_without_client_id_keeps_metadata_and_cached_results(live_service: _LiveService, tmp_path: Path) -> None:
|
||||
"""真实 TCP 上传 PDF,确认自动标识、Files 元数据、附件命名及缓存命中后的正文均正确。"""
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
|
||||
buffer = io.BytesIO()
|
||||
canvas = Canvas(buffer, pagesize=(595, 842))
|
||||
canvas.drawString(72, 770, "Tasks field migration")
|
||||
canvas.drawString(72, 740, "Generated file IDs stay stable across uploads and queries.")
|
||||
canvas.save()
|
||||
data = buffer.getvalue()
|
||||
(tmp_path / "report.v2.pdf").write_bytes(data)
|
||||
payload = {
|
||||
"files": [{"file_name": "report.v2.pdf", "bytes": len(data), "sha256sum": hashlib.sha256(data).hexdigest()}],
|
||||
"tier": "flash",
|
||||
"ocr_mode": "txt",
|
||||
}
|
||||
with _client(live_service) as client:
|
||||
response = client.post("/v1/tasks", json=payload)
|
||||
assert response.status_code == 202, response.text
|
||||
task = response.json()
|
||||
assert task["files"][0]["client_file_id"] == task["uploads"][0]["client_file_id"] == "f1"
|
||||
link = task["uploads"][0]
|
||||
for _ in range(2):
|
||||
upload = client.put(link["upload_url"], content=data, headers=link["upload_headers"])
|
||||
assert upload.status_code == 200, upload.text
|
||||
completed = _wait(client, task)
|
||||
assert completed["status"] == "completed", completed
|
||||
item = completed["files"][0]
|
||||
assert item["client_file_id"] == "f1" and item["file_name"] == "report.v2.pdf" and "filename" not in item
|
||||
result = client.get(item["result_url"].removesuffix("/content"))
|
||||
assert result.status_code == 200 and result.json()["file_name"] == "report.v2.zip"
|
||||
download = client.get(item["result_url"])
|
||||
assert download.status_code == 200
|
||||
assert download.headers["content-disposition"] == 'attachment; filename="report.v2.zip"'
|
||||
(tmp_path / "report.v2.zip").write_bytes(download.content)
|
||||
members = _members(download.content)
|
||||
assert b"Tasks field migration" in members["markdown.md"]
|
||||
assert b"Generated file IDs stay stable" in members["markdown.md"]
|
||||
assert len(json.loads(members["middle_json.json"])["pages"]) == 1
|
||||
cached = client.post("/v1/tasks", json=payload)
|
||||
assert cached.status_code == 202, cached.text
|
||||
cached_task = cached.json()
|
||||
assert cached_task["uploads"] == [] and cached_task["files"][0]["client_file_id"] == "f1"
|
||||
assert cached_task["files"][0]["upload_status"] == "ready"
|
||||
cached_result = _wait(client, cached_task)
|
||||
assert cached_result["status"] == "completed", cached_result
|
||||
cached_download = client.get(cached_result["files"][0]["result_url"])
|
||||
assert cached_download.status_code == 200
|
||||
assert _members(cached_download.content)["markdown.md"] == members["markdown.md"]
|
||||
|
||||
|
||||
def test_router_keeps_tls_proxy_headers_on_the_public_hop_only(tmp_path: Path) -> None:
|
||||
"""TLS 终止代理后的上传、hash 复用及下载保持可用,worker 不继承客户端来源头。"""
|
||||
calls: list[tuple[str, str, dict[str, str]]] = []
|
||||
@@ -309,8 +360,8 @@ def test_hash_preflight_sends_only_missing_file_bytes(live_service: _LiveService
|
||||
"/v1/tasks",
|
||||
headers={"Authorization": "Bearer wrong"},
|
||||
json={
|
||||
"manifest": [
|
||||
{"client_file_id": "f1", "filename": "a.html", "bytes": len(HTML)},
|
||||
"files": [
|
||||
{"client_file_id": "f1", "file_name": "a.html", "bytes": len(HTML)},
|
||||
]
|
||||
},
|
||||
)
|
||||
@@ -401,7 +452,7 @@ def test_upload_over_200_mib_is_accepted(live_service: _LiveService, tmp_path: P
|
||||
with _client(live_service) as client:
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={"tier": "flash", "manifest": [{"client_file_id": "large", "filename": source.name, "bytes": size}]},
|
||||
json={"tier": "flash", "files": [{"client_file_id": "large", "file_name": source.name, "bytes": size}]},
|
||||
)
|
||||
assert response.status_code == 202, response.text
|
||||
task = response.json()
|
||||
@@ -507,7 +558,7 @@ def test_url_task_over_http_downloads_chunked_source(live_service: _LiveService,
|
||||
worker.state.allow_http_source = True
|
||||
item = {
|
||||
"client_file_id": "url-1",
|
||||
"filename": f"logical.{document}",
|
||||
"file_name": f"logical.{document}",
|
||||
"url": f"{source_server.url}/opaque?signature=a%2Fb&part=1",
|
||||
**({"page_range": "2"} if document == "pdf" else {}),
|
||||
}
|
||||
@@ -515,7 +566,7 @@ def test_url_task_over_http_downloads_chunked_source(live_service: _LiveService,
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"manifest": [item],
|
||||
"files": [item],
|
||||
"tier": "flash",
|
||||
"ocr_mode": "txt",
|
||||
"page_range": "1" if document == "pdf" else "",
|
||||
@@ -535,13 +586,13 @@ def test_url_task_over_http_downloads_chunked_source(live_service: _LiveService,
|
||||
assert "authorization" not in requests[0].headers
|
||||
if document == "html":
|
||||
# 同一原始 URL 保留 HTML 来源上下文,重复执行的产物应完全一致。
|
||||
response = client.post("/v1/tasks", json={"tier": "flash", "ocr_mode": "txt", "manifest": [item]})
|
||||
response = client.post("/v1/tasks", json={"tier": "flash", "ocr_mode": "txt", "files": [item]})
|
||||
assert response.status_code == 202, response.text
|
||||
baseline_result = _wait(client, response.json())
|
||||
assert baseline_result["status"] == "completed"
|
||||
baseline_url = baseline_result["files"][0]["result_url"]
|
||||
else:
|
||||
baseline = _submit(client, [(item["filename"], data)], page_range="2")
|
||||
baseline = _submit(client, [(item["file_name"], data)], page_range="2")
|
||||
assert client.put(baseline["uploads"][0]["upload_url"], content=data).status_code == 200
|
||||
baseline_result = _wait(client, baseline)
|
||||
assert baseline_result["status"] == "completed"
|
||||
@@ -581,14 +632,14 @@ class _CloudState:
|
||||
record = self.tasks[task_id]
|
||||
ready, expired = record["ready"], record["expired"]
|
||||
files = []
|
||||
for index, item in enumerate(record["manifest"]):
|
||||
for index, item in enumerate(record["files"]):
|
||||
client_id = item["client_file_id"]
|
||||
status = "completed" if client_id in ready else "waiting_upload"
|
||||
if client_id in expired:
|
||||
status = "failed"
|
||||
if client_id in ready and self.forced_status in {"failed", "running"}:
|
||||
status = self.forced_status
|
||||
if self.forced_status == "partial" and index == len(record["manifest"]) - 1 and client_id in ready:
|
||||
if self.forced_status == "partial" and index == len(record["files"]) - 1 and client_id in ready:
|
||||
status = "failed"
|
||||
result_url = None
|
||||
if status == "completed":
|
||||
@@ -598,7 +649,7 @@ class _CloudState:
|
||||
files.append(
|
||||
{
|
||||
**item,
|
||||
"name": item["filename"],
|
||||
"name": item["file_name"],
|
||||
"upload_status": "ready" if client_id in ready else ("expired" if client_id in expired else "pending"),
|
||||
"status": status,
|
||||
"result_url": result_url,
|
||||
@@ -623,7 +674,7 @@ class _CloudState:
|
||||
"upload_headers": {"Content-Type": "application/octet-stream", "X-Upload-Token": "storage-token"},
|
||||
"expires_at": record["expires_at"],
|
||||
}
|
||||
for item in record["manifest"]
|
||||
for item in record["files"]
|
||||
if item["client_file_id"] not in ready | expired
|
||||
]
|
||||
payload = {
|
||||
@@ -663,10 +714,10 @@ def _cloud_apps(state: _CloudState) -> tuple[FastAPI, FastAPI]:
|
||||
state.gateway_body_sizes.append(len(body))
|
||||
assert len(body) < 4096
|
||||
submitted = await request.json()
|
||||
manifest = submitted["manifest"]
|
||||
manifest = submitted["files"]
|
||||
task_id = f"cloud_{len(state.tasks) + 1}"
|
||||
state.tasks[task_id] = {
|
||||
"manifest": manifest,
|
||||
"files": manifest,
|
||||
"ready": {item["client_file_id"] for item in manifest if item.get("sha256sum") in state.cache},
|
||||
"upload_version": 0,
|
||||
"download_version": 0,
|
||||
@@ -711,7 +762,7 @@ def _cloud_apps(state: _CloudState) -> tuple[FastAPI, FastAPI]:
|
||||
if state.transient_upload and state.upload_attempts[key] == 1:
|
||||
return Response(status_code=503)
|
||||
data = await request.body()
|
||||
item = next(item for item in record["manifest"] if item["client_file_id"] == client_file_id)
|
||||
item = next(item for item in record["files"] if item["client_file_id"] == client_file_id)
|
||||
assert len(data) == item["bytes"]
|
||||
actual = hashlib.sha256(data).hexdigest()
|
||||
assert item.get("sha256sum") in {None, actual}
|
||||
|
||||
@@ -172,7 +172,7 @@ def test_task_formats_use_only_service_authentication(tmp_path: Path, formats: l
|
||||
"""启用 Key 时四种格式都需要服务认证,通过认证后都可执行。"""
|
||||
app = api_server.create_app(upload_dir=str(tmp_path), tier="flash", api_key="secret")
|
||||
with TestClient(app) as client:
|
||||
body = {"manifest": [{"client_file_id": "f1", "filename": "a.html", "bytes": len(HTML)}], "output_formats": formats}
|
||||
body = {"files": [{"client_file_id": "f1", "file_name": "a.html", "bytes": len(HTML)}], "output_formats": formats}
|
||||
assert client.post("/v1/tasks", json=body).status_code == 401
|
||||
headers = {"Authorization": "Bearer secret"}
|
||||
task = _submit(client, [("a.html", HTML)], output_formats=formats, headers=headers)
|
||||
|
||||
@@ -108,8 +108,8 @@ def test_tasks_sdk_upload_cache_and_lost_response(
|
||||
if request.method == "POST":
|
||||
assert request.url.path == "/v1/tasks"
|
||||
body = json.loads(request.content)
|
||||
assert body["manifest"][0]["sha256sum"] == hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
assert body["manifest"][0]["page_range"] == "1"
|
||||
assert body["files"][0]["sha256sum"] == hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
assert body["files"][0]["page_range"] == "1"
|
||||
assert body["ocr_mode"] == "txt" and body["tier"] == "flash"
|
||||
return httpx.Response(202, json=copy.deepcopy(state))
|
||||
if request.method == "PUT":
|
||||
@@ -270,7 +270,7 @@ def test_task_model_output_requires_default() -> None:
|
||||
"""不把原本单文件的 HTML/DOCX 请求隐式改成 ZIP。"""
|
||||
with pytest.raises(ValueError, match="requires default"):
|
||||
CreateTaskRequest(
|
||||
manifest=[{"client_file_id": "file", "filename": "a.pdf", "bytes": 1}],
|
||||
files=[{"client_file_id": "file", "file_name": "a.pdf", "bytes": 1}],
|
||||
output_formats=["html"],
|
||||
include_model_output=True,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user