Auth pillar 從 OAuth 2.0 resource server 改成 pre-shared API key (visionA ↔ converter 1:1 internal trust)。新增 GET /api/v1/jobs/:id/result streaming endpoint 給 visionA backend 中轉 NEF 下載。 Phase A(auth 切換): - 新增 apiKeyMiddleware(constant-time compare、tokenFingerprint、4 audit events) - 砍 OAuth middleware + JWKS(保留 oauthClient 供 promote → FAA 使用) - 4 個 endpoint 換掛 requireApiKey - 加 TRUST_PROXY env + Express trust proxy 設定(forensic source_ip) Phase B(/result endpoint): - streaming NEF download with 5min timeout + concurrent cap 10 - Two-tier rate limit(burst 5/10s + sustained 20/min) - Bandwidth quota(1 GB/hr + 6 GB/24hr)by token_fingerprint - Range header silently ignored + Accept-Ranges: none - filename quote-escape + RFC 5987 fallback + sanitize - 8 個 /result audit events(forensic 完整) 設計演進記錄:docs/TODO-visionA-integration-v2.md(5/2 OAuth → 5/16 API key → 5/16 download via converter;對應 visionA repo ADR-015/016) Tests: 597 → 666 (+69)、29 suites all pass Security: APPROVE WITH CONDITIONS(單 instance 部署、6 新 env、24hr 監控) npm audit: 3 vuln → 0(transitive AWS SDK xml chain) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1801 lines
66 KiB
YAML
1801 lines
66 KiB
YAML
openapi: 3.0.3
|
||
|
||
# =============================================================================
|
||
# Kneron Converter — Phase 1 對外 API
|
||
#
|
||
# 此 spec 對齊「實作端」(apps/task-scheduler/src/routes/v1/`),
|
||
# 與 TDD.md §1 在以下幾處刻意不一致(皆為實作端進化、TDD 尚未同步):
|
||
#
|
||
# 1. status 映射對外只用 `created` / `running` / `completed` / `failed`
|
||
# (TDD §2.7.1 仍提到內部大寫 ONNX/BIE/NEF;對外 spec 不暴露內部值)
|
||
# 2. GET /jobs 採 cursor-based 分頁(base64-url-encoded opaque string),
|
||
# 非 TDD §1.4.4 的 offset 欄位
|
||
# 3. GET /jobs 列表回應使用 `{jobs, total, next_cursor}`,非 TDD 的
|
||
# `{items, limit, offset, total}`
|
||
# 4. GET /jobs/:id 回應實作端會 strip 內部欄位 `created_by_client_id`,
|
||
# 不對外曝露
|
||
# 5. model 副檔名只接受 `.onnx` / `.tflite`(PRD §4.1 F-01;TDD §1.4.2
|
||
# 列了 6 種但 PRD 才是對 user-facing 的合約 — doc-review m6 已記錄)
|
||
# 6. ref_image per-file 上限 10MB(Sec C2)— TDD §1.4.2 沒明確此限制
|
||
# 7. promote response 不含 part-failure 結構;採「全成功 200 / 任一失敗 502」
|
||
# 模型(Phase 1 簡化決策,TDD §1.4.5 範例描述部分失敗為 502 + details,
|
||
# 實作端因 stream 模型難以原子化已改採全失敗 502 + classifyFaaError)
|
||
#
|
||
# 兼容說明:spec 為對外契約來源,當 TDD 與實作不一致時,以實作 + 此 spec 為準。
|
||
# 預期下輪 TDD 維護會同步上述差異。
|
||
# =============================================================================
|
||
|
||
info:
|
||
title: Kneron Converter API
|
||
description: |
|
||
Kneron Model Converter 對外 API(Phase 1)。
|
||
|
||
本服務提供深度學習模型的轉檔服務(ONNX → BIE → NEF),目標是讓上游
|
||
應用(如 visionA-backend)能:
|
||
|
||
1. 上傳原始模型 + 參考圖片 → 建立轉檔 job
|
||
2. Polling job 狀態,直到 `completed` 或 `failed`
|
||
3. 把成功的結果檔 promote(推送)到 File Access Agent / NAS 模型庫
|
||
|
||
## 認證(Phase 0.8b 起)
|
||
|
||
所有 `/api/v1/*` 端點都需要 `Authorization: Bearer <CONVERTER_API_KEY>`。
|
||
|
||
Converter 採 **pre-shared API key**(1:1 internal trust,取代 OAuth resource-server 模式)。
|
||
API key 即「caller 是 visionA」的完整證明,不分 read/write scope,不檢查 audience / tenant。
|
||
|
||
- 產生:`openssl rand -hex 32`(64 hex chars / 128 bits 熵)
|
||
- visionA 與 converter 兩端使用**完全相同字串**
|
||
- 詳見 `docs/autoflow/04-architecture/auth.md`
|
||
|
||
### Audit log(Phase 0.8b A7)
|
||
|
||
每個 `/api/v1/*` request(成功 / 失敗皆然)都會寫一筆 audit log,含 `source_ip`、
|
||
`token_fingerprint`(sha256 前 12 hex chars、不可逆推 token)、`request_id`、
|
||
`http_method`、`http_path`。詳見 README §7.1.5。對 visionA 端 awareness:請避免
|
||
在不同 caller instance 間混用同一個 IP 來源(否則 forensic 區分能力下降)。
|
||
|
||
> **歷史**:Phase 0.8b 之前曾規劃 OAuth `client_credentials` + JWT 驗證
|
||
> (`aud=kneron_converter_api`、`scope=converter:job.{read,write}`),
|
||
> 但因 1:1 trust 場景下 OAuth 過度設計、且 stage 撞 4 個 blocker(見 visionA repo
|
||
> `ADR-014` / `ADR-015` v2.1),改採 API key。converter → FAA 的 OAuth
|
||
> `client_credentials` 鏈條(promote 階段內部用)**保留不動**。
|
||
|
||
## user_id 與 trust boundary
|
||
|
||
`user_id` 不是來自 auth credential(API key 也無 user 概念),而是 multipart form
|
||
field(POST)或 query string(GET)。Converter **完全信任**呼叫端帶來的 user_id 是
|
||
對的,不做 user 層級 ACL。
|
||
|
||
這是 Phase 1 刻意接受的設計風險,詳見:
|
||
`.autoflow/04-architecture/security.md` § Trust Boundary。
|
||
|
||
## 錯誤格式
|
||
|
||
所有 4xx / 5xx 一律使用:
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "snake_case_code",
|
||
"message": "human readable zh-TW",
|
||
"details": { "..." },
|
||
"request_id": "uuid-v4"
|
||
}
|
||
}
|
||
```
|
||
|
||
`details` 欄位視 code 而定(schema 中各 example 已展示)。
|
||
|
||
## Phase 1 已知限制 / 接受風險
|
||
|
||
- `user_id` 信任邊界:見 security.md
|
||
- 大檔上傳(500MB)依賴 Nginx `client_max_body_size`,後端 multer
|
||
在 OAuth 驗證前已開始 buffer;建議 Nginx vhost 設 `client_max_body_size 600M`
|
||
- 單 Scheduler instance(per-process rate limiter / upload concurrency)
|
||
- Crash 即 Reset:未完成的 job 在 Scheduler / Worker crash 後不保證恢復
|
||
|
||
version: 1.0.0
|
||
contact:
|
||
name: Kneron Converter Team
|
||
license:
|
||
name: MIT
|
||
|
||
servers:
|
||
- url: https://your-converter.example.com
|
||
description: 部署範例 — 替換為實際 Converter public host
|
||
- url: http://localhost:4000
|
||
description: 本地開發(無 Nginx 反代理)
|
||
|
||
# =============================================================================
|
||
# Tags
|
||
# =============================================================================
|
||
|
||
tags:
|
||
- name: Health
|
||
description: 服務健康檢查(公開、無需認證)
|
||
- name: Jobs
|
||
description: 轉檔 job 生命週期管理
|
||
- name: Promote
|
||
description: 結果檔搬移到 File Access Agent
|
||
- name: Phase 2 (Reserved)
|
||
description: 預留路由,Phase 1 一律回 501 not_implemented
|
||
|
||
# =============================================================================
|
||
# 全域 security default:除標明 security: [] 外,所有 path 都需 ApiKeyAuth(Bearer scheme)
|
||
# =============================================================================
|
||
|
||
security:
|
||
- ApiKeyAuth: []
|
||
|
||
paths:
|
||
|
||
# ===========================================================================
|
||
# Health
|
||
# ===========================================================================
|
||
|
||
/health:
|
||
get:
|
||
tags: [Health]
|
||
summary: 健康檢查
|
||
operationId: getHealth
|
||
description: |
|
||
回傳服務與依賴(Redis / Member Center / File Access Agent)健康狀態。
|
||
|
||
- **公開**:不需要 Authorization
|
||
- **三層 status**:
|
||
- `healthy`:所有依賴連通 → HTTP 200
|
||
- `degraded`:Redis 連通但 MC / FAA 任一不可達 → HTTP 200
|
||
- `unhealthy`:Redis 不連通 → HTTP 503
|
||
|
||
MC / FAA 使用背景 polling(每 30 秒)+ cache,呼叫此端點本身永遠 < 5ms。
|
||
部署初期(first poll 完成前)狀態為 `pending`。
|
||
security: []
|
||
responses:
|
||
'200':
|
||
description: 服務健康(healthy 或 degraded)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/HealthSnapshot'
|
||
examples:
|
||
healthy:
|
||
summary: 所有依賴正常
|
||
value:
|
||
service: task-scheduler
|
||
status: healthy
|
||
timestamp: '2026-04-25T12:00:00Z'
|
||
redis: connected
|
||
version: '1.0.0'
|
||
dependencies:
|
||
redis: connected
|
||
member_center: reachable
|
||
file_access_agent: reachable
|
||
degraded:
|
||
summary: Redis OK 但 FAA 不可達
|
||
value:
|
||
service: task-scheduler
|
||
status: degraded
|
||
timestamp: '2026-04-25T12:00:00Z'
|
||
redis: connected
|
||
version: '1.0.0'
|
||
dependencies:
|
||
redis: connected
|
||
member_center: reachable
|
||
file_access_agent: unreachable
|
||
'503':
|
||
description: 服務不健康(Redis 斷線)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/HealthSnapshot'
|
||
examples:
|
||
unhealthy:
|
||
summary: Redis 斷線
|
||
value:
|
||
service: task-scheduler
|
||
status: unhealthy
|
||
timestamp: '2026-04-25T12:00:00Z'
|
||
redis: disconnected
|
||
version: '1.0.0'
|
||
dependencies:
|
||
redis: disconnected
|
||
member_center: reachable
|
||
file_access_agent: reachable
|
||
|
||
# ===========================================================================
|
||
# POST /api/v1/jobs — 建立轉檔 job
|
||
# ===========================================================================
|
||
|
||
/api/v1/jobs:
|
||
post:
|
||
tags: [Jobs]
|
||
summary: 建立轉檔 job
|
||
operationId: createJob
|
||
description: |
|
||
以 multipart/form-data 上傳原始模型(+ 可選的參考圖片)+ 參數,
|
||
建立一個轉檔 job,後續會自動跑 ONNX → BIE → NEF pipeline。
|
||
|
||
## 同 user 同時只能有一個進行中 job
|
||
|
||
若 `user_id` 已有未完成 job(`created` 或 `running` 狀態),會回 409
|
||
`user_has_active_job` 並附上既有 job 詳情。
|
||
|
||
## 大檔處理
|
||
|
||
- `model` 單檔 ≤ 500 MB(multer `LIMIT_FILE_SIZE`)
|
||
- `ref_images[]` 每張 ≤ 10 MB、共 ≤ 100 張
|
||
- 部署層 Nginx vhost 應設 `client_max_body_size 600M`
|
||
- 建議 client 使用 chunked transfer + 連線重試
|
||
|
||
## 寫入順序(M5 方案 A)
|
||
|
||
1. validate auth + multipart fields
|
||
2. 先寫 MinIO(input + ref_images)— 失敗 → 502 storage_unavailable,
|
||
Redis 完全乾淨
|
||
3. Lua script 原子寫入 active_job lock + job record + user index
|
||
4. enqueue 第一階段(onnx)
|
||
5. 回 201 `created`
|
||
|
||
security:
|
||
- ApiKeyAuth: []
|
||
parameters:
|
||
- $ref: '#/components/parameters/XRequestId'
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
multipart/form-data:
|
||
schema:
|
||
$ref: '#/components/schemas/CreateJobRequest'
|
||
encoding:
|
||
model:
|
||
contentType: application/octet-stream
|
||
'ref_images[]':
|
||
contentType: image/jpeg, image/png, application/octet-stream
|
||
examples:
|
||
minimal:
|
||
summary: 最小必填欄位
|
||
description: |
|
||
curl 範例:
|
||
|
||
```
|
||
curl -X POST https://your-converter.example.com/api/v1/jobs \
|
||
-H "Authorization: Bearer <bearer-token>" \
|
||
-F "model=@./model.onnx" \
|
||
-F "user_id=alice" \
|
||
-F "model_id=1001" \
|
||
-F "version=v1.0.0" \
|
||
-F "platform=520"
|
||
```
|
||
with_ref_images:
|
||
summary: 含 ref_images 與所有 enable flag
|
||
description: |
|
||
```
|
||
curl -X POST https://your-converter.example.com/api/v1/jobs \
|
||
-H "Authorization: Bearer <bearer-token>" \
|
||
-F "model=@./model.onnx" \
|
||
-F "ref_images[]=@./img_0.jpg" \
|
||
-F "ref_images[]=@./img_1.jpg" \
|
||
-F "user_id=alice" \
|
||
-F "model_id=1001" \
|
||
-F "version=v1.0.0" \
|
||
-F "platform=520" \
|
||
-F "enable_evaluate=true" \
|
||
-F "enable_sim_fp=false"
|
||
```
|
||
responses:
|
||
'201':
|
||
description: Job 建立成功
|
||
headers:
|
||
X-Request-Id:
|
||
$ref: '#/components/headers/XRequestId'
|
||
X-RateLimit-Limit:
|
||
$ref: '#/components/headers/XRateLimitLimit'
|
||
X-RateLimit-Remaining:
|
||
$ref: '#/components/headers/XRateLimitRemaining'
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/CreateJobResponse'
|
||
examples:
|
||
created:
|
||
value:
|
||
job_id: '550e8400-e29b-41d4-a716-446655440000'
|
||
status: created
|
||
stage: onnx
|
||
progress: 0
|
||
created_at: '2026-04-25T12:00:00Z'
|
||
expires_at: '2026-05-02T12:00:00Z'
|
||
user_id: alice
|
||
'400':
|
||
description: 欄位驗證失敗 / multipart 解析失敗
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
validation_error:
|
||
summary: model_id 不在範圍
|
||
value:
|
||
error:
|
||
code: validation_error
|
||
message: 欄位驗證失敗
|
||
details:
|
||
fields:
|
||
- field: model_id
|
||
message: model_id 範圍必須在 1 ~ 65535
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
invalid_multipart:
|
||
summary: 不是 multipart
|
||
value:
|
||
error:
|
||
code: invalid_multipart
|
||
message: multipart 解析失敗:LIMIT_UNEXPECTED_FILE
|
||
details:
|
||
code: LIMIT_UNEXPECTED_FILE
|
||
field: model
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'409':
|
||
description: 該 user_id 已有進行中 job
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
user_has_active_job:
|
||
value:
|
||
error:
|
||
code: user_has_active_job
|
||
message: 使用者目前已有進行中的轉檔任務
|
||
details:
|
||
active_job_id: 550e8400-e29b-41d4-a716-446655440000
|
||
active_job_status: running
|
||
active_job_stage: bie
|
||
active_job_progress: 45
|
||
active_job_created_at: '2026-04-25T12:00:00Z'
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'413':
|
||
description: 上傳檔案超過大小上限
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
model_too_large:
|
||
summary: model 檔超過 500MB
|
||
value:
|
||
error:
|
||
code: file_too_large
|
||
message: 上傳檔案超過 500MB 上限
|
||
details:
|
||
field: model
|
||
limit_bytes: 524288000
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
ref_image_too_large:
|
||
summary: 單張 ref_image 超過 10MB
|
||
value:
|
||
error:
|
||
code: file_too_large
|
||
message: ref_image 超過單張 10485760 bytes 上限
|
||
details:
|
||
field: 'ref_images[3]'
|
||
size_bytes: 12000000
|
||
limit_bytes: 10485760
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'429':
|
||
$ref: '#/components/responses/RateLimited'
|
||
'500':
|
||
description: 伺服器設定錯或其他內部錯誤
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
misconfiguration:
|
||
value:
|
||
error:
|
||
code: misconfiguration
|
||
message: POST /api/v1/jobs 需 STORAGE_BACKEND=minio
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'502':
|
||
description: 物件儲存(MinIO)短暫無法寫入
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
storage_unavailable:
|
||
value:
|
||
error:
|
||
code: storage_unavailable
|
||
message: 檔案儲存服務暫時無法使用,請稍後重試
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'503':
|
||
description: |
|
||
兩種情境:
|
||
- `service_busy`:並發 upload 超過 process semaphore 上限
|
||
- `service_unavailable`:server 端 `CONVERTER_API_KEY` env 未設定(fail-secure)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
service_busy:
|
||
value:
|
||
error:
|
||
code: service_busy
|
||
message: 系統繁忙中,請稍後重試
|
||
details:
|
||
retry_after_seconds: 30
|
||
max_concurrent: 5
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
api_key_not_configured:
|
||
value:
|
||
error:
|
||
code: service_unavailable
|
||
message: API key not configured
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
|
||
# =========================================================================
|
||
# GET /api/v1/jobs — Recovery 列表(user_id 必填)
|
||
# =========================================================================
|
||
|
||
get:
|
||
tags: [Jobs]
|
||
summary: 列出 job(Recovery / polling)
|
||
operationId: listJobs
|
||
description: |
|
||
列出指定 `user_id` 的 job 清單,支援 status 過濾與 cursor-based 分頁。
|
||
|
||
## 為什麼 user_id 必填
|
||
|
||
Phase 1 強制 user_id 必填,避免「不帶條件的全掃」與資訊洩露。
|
||
未來若新增 admin scope 可再放寬(TDD §8.1 預留)。
|
||
|
||
## 隔離
|
||
|
||
列出的 job 一律自動以呼叫端 `client_id` 過濾 — 即同一 user_id 但由不同 client
|
||
建立的 job 不會出現在結果中。Phase 0.8b A3 起,API key 路線下 `client_id` 寫死為
|
||
`'visionA-service'`;隔離邏輯仍保留供未來多 caller 擴展用。
|
||
|
||
## 分頁
|
||
|
||
使用 base64-url-encoded opaque cursor。client 不該假設 cursor
|
||
內容格式(未來可能改為 keyset)。當沒有更多資料時 `next_cursor: null`。
|
||
security:
|
||
- ApiKeyAuth: []
|
||
parameters:
|
||
- name: user_id
|
||
in: query
|
||
required: true
|
||
description: |
|
||
必填。1-128 字元,只允許 `^[A-Za-z0-9._-]+$`(嚴格白名單,
|
||
擋 XSS / Redis key injection / log injection)
|
||
schema:
|
||
type: string
|
||
pattern: '^[A-Za-z0-9._-]{1,128}$'
|
||
example: alice
|
||
- name: status
|
||
in: query
|
||
required: false
|
||
description: |
|
||
過濾條件:
|
||
|
||
- `in_progress`:預設值,相當於 `created` ∪ `running`
|
||
- `completed` / `failed` / `all`
|
||
schema:
|
||
type: string
|
||
enum: [in_progress, completed, failed, all]
|
||
default: in_progress
|
||
- name: limit
|
||
in: query
|
||
required: false
|
||
description: 一頁筆數,1-50(預設 10)
|
||
schema:
|
||
type: integer
|
||
minimum: 1
|
||
maximum: 50
|
||
default: 10
|
||
- name: cursor
|
||
in: query
|
||
required: false
|
||
description: |
|
||
base64-url-encoded opaque cursor。從上一頁的 `next_cursor`
|
||
欄位取得。第一頁不要帶。
|
||
schema:
|
||
type: string
|
||
example: eyJvZmZzZXQiOjEwfQ
|
||
- $ref: '#/components/parameters/XRequestId'
|
||
responses:
|
||
'200':
|
||
description: 查詢成功
|
||
headers:
|
||
X-Request-Id:
|
||
$ref: '#/components/headers/XRequestId'
|
||
X-RateLimit-Limit:
|
||
$ref: '#/components/headers/XRateLimitLimit'
|
||
X-RateLimit-Remaining:
|
||
$ref: '#/components/headers/XRateLimitRemaining'
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ListJobsResponse'
|
||
examples:
|
||
with_results:
|
||
value:
|
||
jobs:
|
||
- job_id: 550e8400-e29b-41d4-a716-446655440000
|
||
user_id: alice
|
||
status: running
|
||
stage: bie
|
||
progress: 45
|
||
stage_progress: 60
|
||
created_at: '2026-04-25T12:00:00Z'
|
||
updated_at: '2026-04-25T12:05:30Z'
|
||
expires_at: '2026-05-02T12:00:00Z'
|
||
stage_timings:
|
||
onnx:
|
||
started_at: '2026-04-25T12:00:00Z'
|
||
completed_at: '2026-04-25T12:02:10Z'
|
||
bie:
|
||
started_at: '2026-04-25T12:02:15Z'
|
||
completed_at: null
|
||
nef:
|
||
started_at: null
|
||
completed_at: null
|
||
input:
|
||
filename: model.onnx
|
||
object_key: jobs/550e8400-e29b-41d4-a716-446655440000/input/model.onnx
|
||
size_bytes: 204800000
|
||
ref_images_count: 0
|
||
result_object_keys: null
|
||
error: null
|
||
parameters:
|
||
model_id: 1001
|
||
version: v1.0.0
|
||
platform: '520'
|
||
enable_evaluate: false
|
||
enable_sim_fp: false
|
||
enable_sim_fixed: false
|
||
enable_sim_hw: false
|
||
metadata: {}
|
||
total: 1
|
||
next_cursor: null
|
||
empty:
|
||
value:
|
||
jobs: []
|
||
total: 0
|
||
next_cursor: null
|
||
'400':
|
||
description: 查詢參數驗證失敗(user_id 缺漏、status 不在 enum 等)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
missing_user_id:
|
||
value:
|
||
error:
|
||
code: validation_error
|
||
message: 查詢參數驗證失敗
|
||
details:
|
||
fields:
|
||
- field: user_id
|
||
message: user_id 必填,1-128 字元,僅可包含英數字 / `.` / `_` / `-`
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'429':
|
||
$ref: '#/components/responses/RateLimited'
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
# ===========================================================================
|
||
# GET /api/v1/jobs/:id — 單一 job 狀態 + ETag
|
||
# ===========================================================================
|
||
|
||
/api/v1/jobs/{id}:
|
||
parameters:
|
||
- $ref: '#/components/parameters/JobIdPath'
|
||
get:
|
||
tags: [Jobs]
|
||
summary: 取得單一 job 狀態
|
||
operationId: getJob
|
||
description: |
|
||
取得單一 job 詳情。支援 ETag 304。
|
||
|
||
## Client 隔離
|
||
|
||
即使 jobId 真實存在,若呼叫端 `client_id` 與 job 的 `created_by_client_id`
|
||
不符,**一律回 404**(不洩漏存在性)。Phase 0.8b A3 起,API key 路線下
|
||
`client_id` 寫死為 `'visionA-service'`。
|
||
|
||
## ETag
|
||
|
||
- response 帶 `ETag: W/"<sha256-of-updated_at>"`(weak ETag)
|
||
- client 後續 polling 帶 `If-None-Match: <prev etag>` →
|
||
若 job 未變化回 304(無 body)
|
||
|
||
security:
|
||
- ApiKeyAuth: []
|
||
parameters:
|
||
- name: If-None-Match
|
||
in: header
|
||
required: false
|
||
description: 上次 polling 拿到的 ETag。命中則回 304。
|
||
schema:
|
||
type: string
|
||
example: W/"a1b2c3d4..."
|
||
- $ref: '#/components/parameters/XRequestId'
|
||
responses:
|
||
'200':
|
||
description: Job 詳情
|
||
headers:
|
||
ETag:
|
||
schema:
|
||
type: string
|
||
description: weak ETag,hash(updated_at)
|
||
X-Request-Id:
|
||
$ref: '#/components/headers/XRequestId'
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/Job'
|
||
examples:
|
||
running:
|
||
value:
|
||
job_id: 550e8400-e29b-41d4-a716-446655440000
|
||
user_id: alice
|
||
status: running
|
||
stage: bie
|
||
progress: 45
|
||
stage_progress: 60
|
||
created_at: '2026-04-25T12:00:00Z'
|
||
updated_at: '2026-04-25T12:05:30Z'
|
||
expires_at: '2026-05-02T12:00:00Z'
|
||
stage_timings:
|
||
onnx:
|
||
started_at: '2026-04-25T12:00:00Z'
|
||
completed_at: '2026-04-25T12:02:10Z'
|
||
bie:
|
||
started_at: '2026-04-25T12:02:15Z'
|
||
completed_at: null
|
||
nef:
|
||
started_at: null
|
||
completed_at: null
|
||
input:
|
||
filename: model.onnx
|
||
object_key: jobs/550e8400-e29b-41d4-a716-446655440000/input/model.onnx
|
||
size_bytes: 204800000
|
||
ref_images_count: 0
|
||
result_object_keys: null
|
||
error: null
|
||
parameters:
|
||
model_id: 1001
|
||
version: v1.0.0
|
||
platform: '520'
|
||
enable_evaluate: false
|
||
enable_sim_fp: false
|
||
enable_sim_fixed: false
|
||
enable_sim_hw: false
|
||
metadata: {}
|
||
completed:
|
||
value:
|
||
job_id: 550e8400-e29b-41d4-a716-446655440000
|
||
user_id: alice
|
||
status: completed
|
||
stage: null
|
||
progress: 100
|
||
stage_progress: 100
|
||
created_at: '2026-04-25T12:00:00Z'
|
||
updated_at: '2026-04-25T12:08:30Z'
|
||
expires_at: '2026-05-02T12:00:00Z'
|
||
stage_timings:
|
||
onnx:
|
||
started_at: '2026-04-25T12:00:00Z'
|
||
completed_at: '2026-04-25T12:02:10Z'
|
||
bie:
|
||
started_at: '2026-04-25T12:02:15Z'
|
||
completed_at: '2026-04-25T12:05:00Z'
|
||
nef:
|
||
started_at: '2026-04-25T12:05:05Z'
|
||
completed_at: '2026-04-25T12:08:30Z'
|
||
input:
|
||
filename: model.onnx
|
||
object_key: jobs/550e8400-e29b-41d4-a716-446655440000/input/model.onnx
|
||
size_bytes: 204800000
|
||
ref_images_count: 0
|
||
result_object_keys:
|
||
onnx: jobs/550e8400-e29b-41d4-a716-446655440000/output/model.onnx
|
||
bie: jobs/550e8400-e29b-41d4-a716-446655440000/output/model.bie
|
||
nef: jobs/550e8400-e29b-41d4-a716-446655440000/output/model.nef
|
||
error: null
|
||
parameters:
|
||
model_id: 1001
|
||
version: v1.0.0
|
||
platform: '520'
|
||
enable_evaluate: false
|
||
enable_sim_fp: false
|
||
enable_sim_fixed: false
|
||
enable_sim_hw: false
|
||
metadata: {}
|
||
failed:
|
||
value:
|
||
job_id: 550e8400-e29b-41d4-a716-446655440000
|
||
user_id: alice
|
||
status: failed
|
||
stage: bie
|
||
progress: 33
|
||
stage_progress: 0
|
||
created_at: '2026-04-25T12:00:00Z'
|
||
updated_at: '2026-04-25T12:03:00Z'
|
||
expires_at: '2026-05-02T12:00:00Z'
|
||
stage_timings:
|
||
onnx:
|
||
started_at: '2026-04-25T12:00:00Z'
|
||
completed_at: '2026-04-25T12:02:10Z'
|
||
bie:
|
||
started_at: '2026-04-25T12:02:15Z'
|
||
completed_at: null
|
||
nef:
|
||
started_at: null
|
||
completed_at: null
|
||
input:
|
||
filename: model.onnx
|
||
object_key: jobs/550e8400-e29b-41d4-a716-446655440000/input/model.onnx
|
||
size_bytes: 204800000
|
||
ref_images_count: 0
|
||
result_object_keys: null
|
||
error:
|
||
stage: bie
|
||
code: quantization_failed
|
||
message: 參考圖片不足或格式不符,BIE 量化階段失敗
|
||
parameters:
|
||
model_id: 1001
|
||
version: v1.0.0
|
||
platform: '520'
|
||
enable_evaluate: false
|
||
enable_sim_fp: false
|
||
enable_sim_fixed: false
|
||
enable_sim_hw: false
|
||
metadata: {}
|
||
'304':
|
||
description: ETag 命中,job 自上次取以來無變化
|
||
headers:
|
||
ETag:
|
||
schema:
|
||
type: string
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/JobNotFound'
|
||
'429':
|
||
$ref: '#/components/responses/RateLimited'
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
delete:
|
||
tags: [Phase 2 (Reserved)]
|
||
summary: '[Phase 2] 取消 / 刪除 job'
|
||
operationId: deleteJob
|
||
description: |
|
||
Phase 2 規劃的端點。Phase 1 一律回 501 `not_implemented`。
|
||
deprecated: false
|
||
security:
|
||
- ApiKeyAuth: []
|
||
responses:
|
||
'501':
|
||
$ref: '#/components/responses/NotImplemented'
|
||
|
||
# ===========================================================================
|
||
# POST /api/v1/jobs/:id/promote — 把結果檔搬到 FAA
|
||
# ===========================================================================
|
||
|
||
/api/v1/jobs/{id}/promote:
|
||
parameters:
|
||
- $ref: '#/components/parameters/JobIdPath'
|
||
post:
|
||
tags: [Promote]
|
||
summary: 把成功結果檔 PUT 到 File Access Agent
|
||
operationId: promoteJob
|
||
description: |
|
||
把已完成(`status=completed`)的 job 指定 stage 結果檔,stream PUT 到
|
||
File Access Agent(NAS 模型庫)。
|
||
|
||
## 冪等
|
||
|
||
- 若 job 已 promoted 過 → 直接回 200 + 既有 promoted_object_keys
|
||
(不重打 FAA、不重新讀 MinIO)
|
||
- 同 `target_object_key` 多次 PUT,FAA 端會覆蓋(client 安全重試)
|
||
|
||
## 序列執行
|
||
|
||
多 target 在 server 端**序列**處理(避免對 FAA 並發壓力與 OOM)。
|
||
|
||
## 重試策略
|
||
|
||
FAA 5xx / network timeout:server 端內部已重試最多 2 次(500ms / 2s
|
||
指數退避)。client 不需要再重試 502 之外的 case。
|
||
|
||
FAA 401:server 自動 invalidate token + 重取一次。仍 401 → 503
|
||
`auth_service_unavailable`。
|
||
|
||
security:
|
||
- ApiKeyAuth: []
|
||
parameters:
|
||
- $ref: '#/components/parameters/XRequestId'
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/PromoteRequest'
|
||
examples:
|
||
single_target:
|
||
value:
|
||
targets:
|
||
- source: nef
|
||
target_object_key: visionA/models/alice/m-1001/v1.0.0/out.nef
|
||
multi_target:
|
||
value:
|
||
targets:
|
||
- source: bie
|
||
target_object_key: visionA/models/alice/m-1001/v1.0.0/out.bie
|
||
- source: nef
|
||
target_object_key: visionA/models/alice/m-1001/v1.0.0/out.nef
|
||
responses:
|
||
'200':
|
||
description: Promote 成功(或冪等命中)
|
||
headers:
|
||
X-Request-Id:
|
||
$ref: '#/components/headers/XRequestId'
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/PromoteResponse'
|
||
examples:
|
||
success:
|
||
value:
|
||
job_id: 550e8400-e29b-41d4-a716-446655440000
|
||
promoted:
|
||
- source: nef
|
||
target_object_key: visionA/models/alice/m-1001/v1.0.0/out.nef
|
||
size_bytes: 10485760
|
||
file_access_agent_etag: 'abc123'
|
||
promoted_at: '2026-04-25T12:30:00Z'
|
||
'400':
|
||
description: targets 格式錯誤
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
empty_targets:
|
||
value:
|
||
error:
|
||
code: validation_error
|
||
message: targets 不可為空
|
||
details:
|
||
fields:
|
||
- field: targets
|
||
message: must contain at least 1 item
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/JobNotFound'
|
||
'409':
|
||
description: Job 尚未完成或指定 source 沒有產出
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
not_ready:
|
||
value:
|
||
error:
|
||
code: job_not_ready_for_promote
|
||
message: Job 尚未完成,無法 promote
|
||
details:
|
||
current_status: ONNX
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
source_not_available:
|
||
value:
|
||
error:
|
||
code: source_not_available
|
||
message: Job 沒有 bie 階段的結果可 promote
|
||
details:
|
||
source: bie
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'422':
|
||
description: target_object_key 格式不合法
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
invalid_object_key:
|
||
value:
|
||
error:
|
||
code: invalid_object_key
|
||
message: target_object_key 格式不合法
|
||
details:
|
||
field: 'targets[0].target_object_key'
|
||
reason: 不可為空、不可含 .. / 反斜線 / 控制字元 / 開頭斜線 / ? / # / %;長度 ≤ 1024
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'429':
|
||
$ref: '#/components/responses/RateLimited'
|
||
'502':
|
||
description: File Access Agent 不可用或拒絕請求
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
gateway_unavailable:
|
||
value:
|
||
error:
|
||
code: file_gateway_unavailable
|
||
message: 檔案存取服務暫時無法使用,請稍後重試
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
gateway_rejected:
|
||
summary: FAA 4xx 拒絕(如 target_object_key 命名違規)
|
||
value:
|
||
error:
|
||
code: file_gateway_unavailable
|
||
message: 檔案存取服務拒絕此請求
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'503':
|
||
description: |
|
||
兩種情境:
|
||
- `auth_service_unavailable`:promote 階段 converter → Member Center 取 FAA token 失敗
|
||
- `service_unavailable`:server 端 `CONVERTER_API_KEY` env 未設定(fail-secure)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
auth_unavailable:
|
||
value:
|
||
error:
|
||
code: auth_service_unavailable
|
||
message: 認證服務目前無法簽發必要 token,請稍後重試
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
api_key_not_configured:
|
||
value:
|
||
error:
|
||
code: service_unavailable
|
||
message: API key not configured
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
|
||
# ===========================================================================
|
||
# Phase 0.8b Phase B — GET /api/v1/jobs/{id}/result(streaming proxy)
|
||
# ===========================================================================
|
||
|
||
/api/v1/jobs/{id}/result:
|
||
parameters:
|
||
- $ref: '#/components/parameters/JobIdPath'
|
||
get:
|
||
tags: [Result]
|
||
summary: 'Stream NEF result binary for visionA-backend'
|
||
operationId: getJobResult
|
||
description: |
|
||
Phase 0.8b Phase B 新增(取代 Phase 2 `/download-tokens`)。
|
||
|
||
visionA-backend 用此 endpoint 從 Converter Bucket 直接 streaming NEF 結果檔。
|
||
取代「visionA → 拿 delegated download token → FAA」路徑(該路徑因 MC 沒實作而從未跑通)。
|
||
|
||
**安全限制**(per `token_fingerprint = sha256(api_key).slice(0,12)`):
|
||
- Burst rate limit:5 req / 10s(429 + `limit_type: burst`)
|
||
- Sustained rate limit:20 req / 1min(429 + `limit_type: sustained`)
|
||
- Hourly bandwidth quota:1 GB / hr(429 + `limit_type: bandwidth_hourly`)
|
||
- Daily bandwidth quota:6 GB / 24hr(429 + `limit_type: bandwidth_daily`)
|
||
- Concurrent stream cap:10 同時 stream / instance(503 + `Retry-After: 30`)
|
||
- Stream response timeout:5 分鐘(destroy connection)
|
||
|
||
**Range header 處理**:silently ignored、永遠回 200 整段、設 `Accept-Ranges: none`
|
||
(不回 416、不切片 MinIO request)。收到 Range header 時寫 audit log
|
||
`result.range_attempted`(INFO、forensic baseline)。
|
||
|
||
詳見 `docs/autoflow/04-architecture/api/api-result.md`。
|
||
security:
|
||
- ApiKeyAuth: []
|
||
responses:
|
||
'200':
|
||
description: NEF binary stream(完整檔、不支援 Range / partial content)
|
||
headers:
|
||
Content-Type:
|
||
schema:
|
||
type: string
|
||
example: application/octet-stream
|
||
Content-Length:
|
||
schema:
|
||
type: integer
|
||
description: NEF 物件 size bytes(從 MinIO 取得)
|
||
Content-Disposition:
|
||
schema:
|
||
type: string
|
||
example: 'attachment; filename="yolov5s_720.nef"; filename*=UTF-8''''yolov5s_720.nef'
|
||
description: |
|
||
`attachment; filename="<source_filename_stem>_<platform>.nef"; filename*=UTF-8''<encoded>`
|
||
|
||
- `filename`:ASCII-safe + quote-escape
|
||
- `filename*`:RFC 5987 extended(為未來 unicode 預留)
|
||
- 缺 source_filename 或 platform → fallback `job_<jobId>.nef`
|
||
Accept-Ranges:
|
||
schema:
|
||
type: string
|
||
example: none
|
||
description: '明示不支援 Range request(RFC 7233 §2.3)'
|
||
content:
|
||
application/octet-stream:
|
||
schema:
|
||
type: string
|
||
format: binary
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
description: |
|
||
- `job_not_found`:jobID 不存在
|
||
- `result_not_found`:completed 但 result_object_keys 內沒 NEF
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/Error'
|
||
examples:
|
||
JobNotFound:
|
||
value:
|
||
error:
|
||
code: job_not_found
|
||
message: Job 550e8400-e29b-41d4-a716-446655440000 not found
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
ResultNotFound:
|
||
value:
|
||
error:
|
||
code: result_not_found
|
||
message: Job 550e8400-e29b-41d4-a716-446655440000 completed but no NEF result available
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'409':
|
||
description: Job 還沒完成(status !== 'COMPLETED')
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/Error'
|
||
examples:
|
||
NotCompleted:
|
||
value:
|
||
error:
|
||
code: job_not_completed
|
||
message: 'Job 550e8400-e29b-41d4-a716-446655440000 is ONNX; result only available after completion'
|
||
details:
|
||
current_status: ONNX
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'410':
|
||
description: NEF 已過期(converter MinIO lifecycle 清掉 / expires_at < now)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/Error'
|
||
examples:
|
||
Expired:
|
||
value:
|
||
error:
|
||
code: result_expired
|
||
message: 'Job 550e8400-e29b-41d4-a716-446655440000 result expired at 2026-05-10T00:00:00Z; re-convert to get a fresh result'
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'429':
|
||
description: |
|
||
Rate limit 超限或 bandwidth quota 超限。
|
||
- `rate_limit_exceeded`:req-based limit 超限(`limit_type: burst | sustained`)
|
||
- `bandwidth_quota_exceeded`:頻寬 quota 超限(`limit_type: bandwidth_hourly | bandwidth_daily`)
|
||
headers:
|
||
Retry-After:
|
||
schema:
|
||
type: integer
|
||
description: 建議 retry 時間(秒)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/Error'
|
||
examples:
|
||
BurstLimitHit:
|
||
value:
|
||
error:
|
||
code: rate_limit_exceeded
|
||
message: 請求頻率過高,請稍後再試
|
||
details:
|
||
limit_type: burst
|
||
retry_after_seconds: 10
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
BandwidthQuotaHit:
|
||
value:
|
||
error:
|
||
code: bandwidth_quota_exceeded
|
||
message: 下載額度已用完,請稍後再試
|
||
details:
|
||
limit_type: bandwidth_hourly
|
||
retry_after_seconds: 2847
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'502':
|
||
description: MinIO 暫時不可用(5xx / 連線錯誤)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/Error'
|
||
examples:
|
||
StorageUnavailable:
|
||
value:
|
||
error:
|
||
code: storage_unavailable
|
||
message: 無法讀取結果檔,請稍後重試
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
'503':
|
||
description: |
|
||
- `service_busy`:並發 stream 達上限(concurrent cap)
|
||
- `stream_timeout`:response stream 5min timeout
|
||
- `service_unavailable`:CONVERTER_API_KEY 未配置
|
||
headers:
|
||
Retry-After:
|
||
schema:
|
||
type: integer
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/Error'
|
||
examples:
|
||
ServiceBusy:
|
||
value:
|
||
error:
|
||
code: service_busy
|
||
message: 伺服器忙碌中,請稍後再試
|
||
details:
|
||
limit_type: concurrent
|
||
retry_after_seconds: 30
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
|
||
# ===========================================================================
|
||
# Phase 2 預留端點 — 一律 501 not_implemented
|
||
# ===========================================================================
|
||
|
||
/api/v1/jobs/{id}/download-tokens:
|
||
parameters:
|
||
- $ref: '#/components/parameters/JobIdPath'
|
||
post:
|
||
tags: [Phase 2 (Reserved)]
|
||
summary: '[Phase 2] 換 delegated download token'
|
||
operationId: createDownloadTokens
|
||
description: |
|
||
Phase 2 規劃的端點,待 Member Center 完成 delegated token 流程後實作。
|
||
Phase 1 一律回 501 `not_implemented`。
|
||
deprecated: false
|
||
security:
|
||
- ApiKeyAuth: []
|
||
responses:
|
||
'501':
|
||
$ref: '#/components/responses/NotImplemented'
|
||
|
||
# =============================================================================
|
||
# Components
|
||
# =============================================================================
|
||
|
||
components:
|
||
|
||
securitySchemes:
|
||
ApiKeyAuth:
|
||
type: http
|
||
scheme: bearer
|
||
bearerFormat: APIKey
|
||
description: |
|
||
Phase 0.8b 起,visionA → converter 採 pre-shared API key 認證(1:1 internal trust)。
|
||
|
||
- Header 格式:`Authorization: Bearer <CONVERTER_API_KEY>`
|
||
- Key 為 64 hex chars(128 bits 熵),由 `openssl rand -hex 32` 產生
|
||
- visionA 與 converter 兩端用**完全相同字串**(兩端 env 各自設定)
|
||
- 不分 read/write scope(API key 即「caller 是 visionA」的完整證明)
|
||
- 不檢查 issuer / audience / tenant / expiration
|
||
- Server 端用 `crypto.timingSafeEqual` constant-time compare 防 timing attack
|
||
|
||
失敗:
|
||
- 缺 Authorization / 非 Bearer 格式 / token 為空 / key 不符 → 401 `invalid_token`
|
||
- server `CONVERTER_API_KEY` env 未設定 → 503 `service_unavailable`(fail-secure)
|
||
|
||
詳見 `docs/autoflow/04-architecture/auth.md` §1 + §4。
|
||
|
||
> **歷史**:原先設計用 OAuth Bearer JWT(`BearerAuth` + `OAuth2ClientCredentials`
|
||
> schemes),詳見 visionA repo `ADR-014` / `ADR-015` v2.1。Phase 0.8b 改 API key 後
|
||
> 兩個 OAuth scheme 已從本 spec 移除。
|
||
|
||
parameters:
|
||
|
||
XRequestId:
|
||
name: X-Request-Id
|
||
in: header
|
||
required: false
|
||
description: |
|
||
Trace ID。若 client 帶入則 server 沿用;未帶則 server 產 UUIDv4 並回給 client。
|
||
schema:
|
||
type: string
|
||
format: uuid
|
||
example: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
|
||
JobIdPath:
|
||
name: id
|
||
in: path
|
||
required: true
|
||
description: Job UUIDv4(建立時 server 生成)
|
||
schema:
|
||
type: string
|
||
format: uuid
|
||
example: 550e8400-e29b-41d4-a716-446655440000
|
||
|
||
headers:
|
||
|
||
XRequestId:
|
||
schema:
|
||
type: string
|
||
format: uuid
|
||
description: 請求對應的 trace ID(與 X-Request-Id request header 相同)
|
||
|
||
XRateLimitLimit:
|
||
schema:
|
||
type: integer
|
||
description: 該 client 在當前 window 的最大允許 request 數(預設 300)
|
||
|
||
XRateLimitRemaining:
|
||
schema:
|
||
type: integer
|
||
description: 該 client 當前 window 還剩多少 request 額度
|
||
|
||
responses:
|
||
|
||
Unauthorized:
|
||
description: |
|
||
API key 不符 / 缺 Authorization header / 非 Bearer 格式 / token 為空。
|
||
|
||
Phase 0.8b 起,所有 401 一律為 `invalid_token`(沒有 `token_expired` —
|
||
API key 無過期概念;沒有 `insufficient_scope` / `tenant_mismatch` —
|
||
API key 不分 scope / tenant)。
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
missing_header:
|
||
summary: 缺 Authorization header
|
||
value:
|
||
error:
|
||
code: invalid_token
|
||
message: 缺少或格式錯誤的 Authorization header(需為 Bearer <token>)
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
wrong_key:
|
||
summary: API key 不符
|
||
value:
|
||
error:
|
||
code: invalid_token
|
||
message: API key 驗證失敗
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
|
||
ServiceUnavailable:
|
||
description: |
|
||
Server 端 `CONVERTER_API_KEY` env 未設定(fail-secure)。設好 env 重啟即可。
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
api_key_not_configured:
|
||
value:
|
||
error:
|
||
code: service_unavailable
|
||
message: API key not configured
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
|
||
JobNotFound:
|
||
description: |
|
||
Job 不存在 — 或存在但屬於不同 client_id(為避免存在性洩露,一律回此)
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
not_found:
|
||
value:
|
||
error:
|
||
code: job_not_found
|
||
message: Job 不存在
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
|
||
RateLimited:
|
||
description: 超過 per-client_id rate limit
|
||
headers:
|
||
Retry-After:
|
||
schema:
|
||
type: integer
|
||
description: 多少秒後可再嘗試
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
rate_limited:
|
||
value:
|
||
error:
|
||
code: rate_limit_exceeded
|
||
message: 請求頻率過高,請稍後再試
|
||
details:
|
||
retry_after_seconds: 30
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
|
||
NotImplemented:
|
||
description: Phase 2 預留功能,Phase 1 不提供
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/ApiError'
|
||
examples:
|
||
not_implemented:
|
||
value:
|
||
error:
|
||
code: not_implemented
|
||
message: 此端點為 Phase 2 預留,尚未實作
|
||
request_id: 7c6e4f3b-1a2b-4c3d-9e8f-aabbccddeeff
|
||
|
||
schemas:
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 共用:錯誤格式
|
||
# -------------------------------------------------------------------------
|
||
|
||
ApiError:
|
||
type: object
|
||
required: [error]
|
||
properties:
|
||
error:
|
||
type: object
|
||
required: [code, message, request_id]
|
||
properties:
|
||
code:
|
||
type: string
|
||
description: |
|
||
錯誤分類碼(snake_case)。完整清單見 README §Error Codes。
|
||
enum:
|
||
- validation_error
|
||
- invalid_multipart
|
||
- invalid_token
|
||
- job_not_found
|
||
- not_found
|
||
- user_has_active_job
|
||
- job_not_ready_for_promote
|
||
- source_not_available
|
||
- file_too_large
|
||
- invalid_object_key
|
||
- misconfiguration
|
||
- storage_unavailable
|
||
- file_gateway_unavailable
|
||
- auth_service_unavailable
|
||
- service_busy
|
||
- service_unavailable
|
||
- rate_limit_exceeded
|
||
- internal_error
|
||
- not_implemented
|
||
message:
|
||
type: string
|
||
description: 人類可讀的訊息(zh-TW)
|
||
details:
|
||
type: object
|
||
description: 視 code 而定的補充資訊;可能不存在
|
||
additionalProperties: true
|
||
request_id:
|
||
type: string
|
||
description: 對應的 X-Request-Id(uuid)
|
||
format: uuid
|
||
|
||
# -------------------------------------------------------------------------
|
||
# /health
|
||
# -------------------------------------------------------------------------
|
||
|
||
HealthSnapshot:
|
||
type: object
|
||
required: [service, status, timestamp, redis, dependencies]
|
||
properties:
|
||
service:
|
||
type: string
|
||
enum: [task-scheduler]
|
||
status:
|
||
type: string
|
||
enum: [healthy, degraded, unhealthy]
|
||
timestamp:
|
||
type: string
|
||
format: date-time
|
||
redis:
|
||
type: string
|
||
description: 向後相容欄位(與 dependencies.redis 同值)
|
||
enum: [connected, disconnected]
|
||
version:
|
||
type: string
|
||
description: 服務版本
|
||
example: '1.0.0'
|
||
dependencies:
|
||
type: object
|
||
required: [redis, member_center, file_access_agent]
|
||
properties:
|
||
redis:
|
||
type: string
|
||
enum: [connected, disconnected]
|
||
member_center:
|
||
type: string
|
||
enum: [reachable, unreachable, pending]
|
||
file_access_agent:
|
||
type: string
|
||
enum: [reachable, unreachable, pending]
|
||
|
||
# -------------------------------------------------------------------------
|
||
# POST /jobs request body
|
||
# -------------------------------------------------------------------------
|
||
|
||
CreateJobRequest:
|
||
type: object
|
||
required: [model, user_id, model_id, version, platform]
|
||
properties:
|
||
model:
|
||
type: string
|
||
format: binary
|
||
description: |
|
||
原始模型檔(必填)。
|
||
|
||
- 副檔名只接受 `.onnx` / `.tflite`(PRD §4.4)
|
||
- 大小 ≤ 500 MB
|
||
- 不可為空
|
||
ref_images[]:
|
||
type: array
|
||
description: |
|
||
可選的參考圖片陣列(用於 BIE 校正)。
|
||
|
||
- 0 ~ 100 張
|
||
- 每張 ≤ 10 MB
|
||
items:
|
||
type: string
|
||
format: binary
|
||
user_id:
|
||
type: string
|
||
description: |
|
||
**trust boundary**:由呼叫端決定,server 完全信任。
|
||
|
||
限制:1-128 字元,`^[A-Za-z0-9._-]+$`,不可含 `..`
|
||
pattern: '^[A-Za-z0-9._-]{1,128}$'
|
||
example: alice
|
||
model_id:
|
||
type: string
|
||
description: 數字字串,整數值範圍 1 ~ 65535
|
||
example: '1001'
|
||
version:
|
||
type: string
|
||
description: |
|
||
版本識別。1-32 字元,`^[A-Za-z0-9._-]+$`(拒含 XSS / 控制字元)
|
||
pattern: '^[A-Za-z0-9._-]{1,32}$'
|
||
example: v1.0.0
|
||
platform:
|
||
type: string
|
||
description: 目標 Kneron 平台
|
||
enum: ['520', '720', '530', '630', '730']
|
||
enable_evaluate:
|
||
type: string
|
||
description: 是否啟用 IP evaluation。`'true'` / `'false'`,缺漏視為 `'false'`
|
||
enum: ['true', 'false']
|
||
default: 'false'
|
||
enable_sim_fp:
|
||
type: string
|
||
description: 是否執行浮點 E2E 模擬。`'true'` / `'false'`
|
||
enum: ['true', 'false']
|
||
default: 'false'
|
||
enable_sim_fixed:
|
||
type: string
|
||
description: 是否執行定點 E2E 模擬。`'true'` / `'false'`
|
||
enum: ['true', 'false']
|
||
default: 'false'
|
||
enable_sim_hw:
|
||
type: string
|
||
description: 是否執行硬體 E2E 模擬。`'true'` / `'false'`
|
||
enum: ['true', 'false']
|
||
default: 'false'
|
||
metadata:
|
||
type: string
|
||
description: |
|
||
可選;若有需為合法 JSON object 字串(不可為 array / null / primitive)。
|
||
未來擴展用,server 原樣保留。
|
||
example: '{"source":"visionA-web"}'
|
||
|
||
CreateJobResponse:
|
||
type: object
|
||
required: [job_id, status, stage, progress, created_at, expires_at, user_id]
|
||
properties:
|
||
job_id:
|
||
type: string
|
||
format: uuid
|
||
description: server 生成的 UUIDv4
|
||
status:
|
||
type: string
|
||
enum: [created]
|
||
description: 建立成功時固定為 `created`
|
||
stage:
|
||
type: string
|
||
enum: [onnx]
|
||
description: 第一階段固定為 `onnx`
|
||
progress:
|
||
type: integer
|
||
enum: [0]
|
||
description: 建立成功時固定為 0
|
||
created_at:
|
||
type: string
|
||
format: date-time
|
||
expires_at:
|
||
type: string
|
||
format: date-time
|
||
description: 建立後 7 天的時間戳,過期 Redis / MinIO 會清掉相關資料
|
||
user_id:
|
||
type: string
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Job 完整 schema(GET / list 共用)
|
||
# -------------------------------------------------------------------------
|
||
|
||
Job:
|
||
type: object
|
||
required:
|
||
- job_id
|
||
- user_id
|
||
- status
|
||
- stage
|
||
- progress
|
||
- stage_progress
|
||
- created_at
|
||
- updated_at
|
||
- expires_at
|
||
- stage_timings
|
||
- input
|
||
- result_object_keys
|
||
- error
|
||
- parameters
|
||
- metadata
|
||
properties:
|
||
job_id:
|
||
type: string
|
||
format: uuid
|
||
user_id:
|
||
type: string
|
||
nullable: true
|
||
status:
|
||
type: string
|
||
enum: [created, running, completed, failed]
|
||
description: |
|
||
對外狀態:
|
||
|
||
- `created`:剛建立,第一階段尚未開工
|
||
- `running`:某個 stage 進行中(看 `stage` 欄位)
|
||
- `completed`:全部完成,`result_object_keys` 有值
|
||
- `failed`:某階段失敗,`error` 有值
|
||
stage:
|
||
type: string
|
||
nullable: true
|
||
enum: [onnx, bie, nef, null]
|
||
description: |
|
||
當前進行中的 stage。`completed` 時為 `null`;
|
||
`failed` 時為失敗發生時的 stage。
|
||
progress:
|
||
type: integer
|
||
minimum: 0
|
||
maximum: 100
|
||
description: 整體 pipeline 進度 0-100
|
||
stage_progress:
|
||
type: integer
|
||
minimum: 0
|
||
maximum: 100
|
||
description: 當前 stage 內的進度(worker 上報;Phase 1 多為 0 或 100)
|
||
created_at:
|
||
type: string
|
||
format: date-time
|
||
updated_at:
|
||
type: string
|
||
format: date-time
|
||
expires_at:
|
||
type: string
|
||
format: date-time
|
||
stage_timings:
|
||
$ref: '#/components/schemas/StageTimings'
|
||
input:
|
||
type: object
|
||
nullable: true
|
||
required: [filename, object_key, size_bytes, ref_images_count]
|
||
properties:
|
||
filename:
|
||
type: string
|
||
nullable: true
|
||
description: 上傳當下的原始檔名(已 sanitize)
|
||
object_key:
|
||
type: string
|
||
nullable: true
|
||
description: Converter Bucket 內的物件 key(內部使用)
|
||
size_bytes:
|
||
type: integer
|
||
nullable: true
|
||
description: 模型檔大小(bytes)
|
||
ref_images_count:
|
||
type: integer
|
||
minimum: 0
|
||
description: 參考圖片數量
|
||
result_object_keys:
|
||
type: object
|
||
nullable: true
|
||
description: |
|
||
僅 `status=completed` 時有值。各 stage 結果檔在 Converter Bucket
|
||
內的 object key(後續 promote 用)。
|
||
properties:
|
||
onnx:
|
||
type: string
|
||
bie:
|
||
type: string
|
||
nef:
|
||
type: string
|
||
error:
|
||
type: object
|
||
nullable: true
|
||
description: 僅 `status=failed` 時有值
|
||
properties:
|
||
stage:
|
||
type: string
|
||
enum: [onnx, bie, nef]
|
||
code:
|
||
type: string
|
||
description: Worker 端的錯誤碼(如 `quantization_failed`)
|
||
message:
|
||
type: string
|
||
parameters:
|
||
type: object
|
||
required: [model_id, version, platform]
|
||
properties:
|
||
model_id:
|
||
type: integer
|
||
minimum: 1
|
||
maximum: 65535
|
||
version:
|
||
type: string
|
||
platform:
|
||
type: string
|
||
enum: ['520', '720', '530', '630', '730']
|
||
enable_evaluate:
|
||
type: boolean
|
||
enable_sim_fp:
|
||
type: boolean
|
||
enable_sim_fixed:
|
||
type: boolean
|
||
enable_sim_hw:
|
||
type: boolean
|
||
metadata:
|
||
type: object
|
||
additionalProperties: true
|
||
description: client 提交時帶的 metadata,server 原樣保留
|
||
|
||
StageTimings:
|
||
type: object
|
||
description: |
|
||
每個 stage 的開始與完成時間。
|
||
|
||
**Phase 1 限制**:`started_at` 是「Scheduler enqueue 該 stage 的時間」,
|
||
非 worker 真的拿起任務的時間。worker 等待 queue 的時間會被算入。
|
||
Phase 2 若需精確區分,由 worker 上報 `worker_started_at`。
|
||
required: [onnx, bie, nef]
|
||
properties:
|
||
onnx:
|
||
$ref: '#/components/schemas/StageTiming'
|
||
bie:
|
||
$ref: '#/components/schemas/StageTiming'
|
||
nef:
|
||
$ref: '#/components/schemas/StageTiming'
|
||
|
||
StageTiming:
|
||
type: object
|
||
nullable: true
|
||
properties:
|
||
started_at:
|
||
type: string
|
||
format: date-time
|
||
nullable: true
|
||
completed_at:
|
||
type: string
|
||
format: date-time
|
||
nullable: true
|
||
|
||
# -------------------------------------------------------------------------
|
||
# GET /jobs (list)
|
||
# -------------------------------------------------------------------------
|
||
|
||
ListJobsResponse:
|
||
type: object
|
||
required: [jobs, total, next_cursor]
|
||
properties:
|
||
jobs:
|
||
type: array
|
||
items:
|
||
$ref: '#/components/schemas/Job'
|
||
total:
|
||
type: integer
|
||
minimum: 0
|
||
description: 過濾條件下的總筆數(不只是當頁)
|
||
next_cursor:
|
||
type: string
|
||
nullable: true
|
||
description: |
|
||
下一頁的 opaque cursor(base64-url)。null 表示已是最後一頁。
|
||
|
||
# -------------------------------------------------------------------------
|
||
# POST /jobs/:id/promote
|
||
# -------------------------------------------------------------------------
|
||
|
||
PromoteRequest:
|
||
type: object
|
||
required: [targets]
|
||
properties:
|
||
targets:
|
||
type: array
|
||
minItems: 1
|
||
maxItems: 10
|
||
description: |
|
||
要 promote 的清單。每個 source 在同一個 request 中只能出現一次(重複會 400)。
|
||
items:
|
||
$ref: '#/components/schemas/PromoteTarget'
|
||
|
||
PromoteTarget:
|
||
type: object
|
||
required: [source, target_object_key]
|
||
properties:
|
||
source:
|
||
type: string
|
||
enum: [onnx, bie, nef]
|
||
description: 要從哪個 stage 結果取
|
||
target_object_key:
|
||
type: string
|
||
maxLength: 1024
|
||
description: |
|
||
File Access Agent 端的目標 key。caller(visionA)決定命名規則。
|
||
|
||
禁止字元(會回 422 invalid_object_key):
|
||
- 空字串
|
||
- 開頭 `/`
|
||
- `..`(path traversal)
|
||
- 反斜線 `\\`(Windows path)
|
||
- 控制字元 / null byte
|
||
- `?` `#` `%`(URL 結構字元 / 雙重編碼攻擊)
|
||
- 長度 > 1024
|
||
example: visionA/models/alice/m-1001/v1.0.0/out.nef
|
||
|
||
PromoteResponse:
|
||
type: object
|
||
required: [job_id, promoted]
|
||
properties:
|
||
job_id:
|
||
type: string
|
||
format: uuid
|
||
promoted:
|
||
type: array
|
||
items:
|
||
type: object
|
||
required: [source, target_object_key, size_bytes, file_access_agent_etag, promoted_at]
|
||
properties:
|
||
source:
|
||
type: string
|
||
enum: [onnx, bie, nef]
|
||
target_object_key:
|
||
type: string
|
||
size_bytes:
|
||
type: integer
|
||
description: PUT 到 FAA 的 bytes 數
|
||
file_access_agent_etag:
|
||
type: string
|
||
nullable: true
|
||
description: FAA 回的 ETag(若有)
|
||
promoted_at:
|
||
type: string
|
||
format: date-time
|