mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-10 18:36:57 +08:00
Plugin polling hooks previously ran against a hollow context: parseTaskResult
and parseBatchResult received {} / nil, buildQueryRequest received a
{task_id, action} map under the misleading name requestBody, and batch hooks
saw only bare task ids. The per-task poller also never looked at the upstream
HTTP status, and every built-in plugin papered over unrecognized bodies with
`|| "IN_PROGRESS"`, so a 404, a revoked key, or a shape the plugin did not
know would sit in IN_PROGRESS for the full 24h TASK_TIMEOUT_MINUTES while
holding the user's pre-charged quota.
Contract (docs/plugin-api v1.d.ts, v1.md, v1.schema.json):
- TaskQueryContext is declared separately from DriverContext and rebuilt from
the persisted Task row: taskId, publicTaskId, action, model, upstreamModel,
baseUrl, apiKey, authHeader, auth, data, state. Query-side requestBody is
removed; the original request is not persisted and hooks that need a
request-derived value must save it into state at submit time.
- parseTaskResult / parseBatchResult receive a third {status, headers}
argument. Batch hooks receive tasks[] with one TaskQueryContext per task.
- NormalizedTaskResult accepts status "UNKNOWN" meaning "I do not recognize
this body". Falling back to IN_PROGRESS for unknown shapes is forbidden;
`plugin lint` warns on the literal.
- parseSubmitResponse / parseTaskResult / parseBatchResult may return `state`.
Task.Data remains a per-round snapshot overwritten on every valid parse;
state is plugin-owned, persisted in TaskPrivateData.PluginState, preserved
when a hook omits it, byte-capped like taskData, and never exposed through
presenter views.
Host (service/task_polling.go, relay/channel/task/jsplugin/adaptor.go):
- TaskPollingAdaptor / BatchTaskPollingAdaptor take *model.Task and the
*http.Response so the adaptor can build the full context; jsplugin is the
only implementation.
- HTTP classification before the plugin sees the body: 2xx -> plugin;
404/410 -> FAILURE and refund; 401/403 -> poll failure plus a channel-scoped
warning, no auto-disable; 429/5xx/transport -> poll failure; other 4xx ->
plugin with the status visible, counted as unrecognized if the plugin still
reports a non-terminal state.
- TaskPrivateData.PollFailures counts consecutive poll failures (transient
HTTP, auth, transport, hook error, UNKNOWN). It is persisted through the
existing UpdateWithStatus CAS so a concurrent terminal transition on another
instance is never clobbered, and reset on any valid 2xx non-terminal parse.
Reaching TASK_POLL_MAX_FAILURES (default 20, <= 0 disables) fails the task
with the last classification and HTTP code in fail_reason and runs the
existing settle/refund chain exactly once. sweepTimedOutTasks and its
1440-minute default are unchanged as the outer backstop.
- Unrecognized bodies are logged at WARN with a bounded redacted copy since
Task.Data is intentionally not overwritten on that path.
Plugins (all ten bumped one patch version):
- jimeng persists the outbound req_key in state and reads it back in
buildQueryRequest, replacing dead reads of ctx.data / ctx.requestBody that
never resolved.
- sunoapi batch hooks read tasks[] instead of the removed requestBody.
- hailuo treats base_resp.status_code != 0 as FAILURE before the status table.
- kling, vidu, sora, alibaba, doubao, hailuo, jimeng return UNKNOWN with the
raw upstream status in reason on table miss.
- google and vertex-ai treat a missing `done` as in-progress: Google
long-running operations omit proto3 default fields, so a running Veo
operation has no `done` key at all. Only a body without an operation name is
UNKNOWN. plugins/veo_poll_test.go locks this so the poll-failure cutoff can
never fail a rendering Veo task.
Tests cover the classification table end to end against a real DB (404
immediate refund, 429xN refund, 401 increments without status change, 2xx
reset, UNKNOWN increments, state preserved vs replaced, PollFailures survives
the CAS write), the query-context shape, UNKNOWN on unrecognized bodies, and
the absence of PluginState/PollFailures from TaskView. Controller tests derive
the kling factory version from the embedded manifest instead of hardcoding it.
178 lines
15 KiB
Markdown
178 lines
15 KiB
Markdown
# Task Plugin API v1
|
|
|
|
Task Plugin v1 has two independent entry surfaces. `meta.routes` registers plugin-owned native URLs; `meta.protocols` claims host-owned protocols without registering or copying their URLs. `apiVersion` remains `1`.
|
|
|
|
## Manifest
|
|
|
|
```js
|
|
export const meta = {
|
|
apiVersion: 1,
|
|
key: "vendor",
|
|
name: "Vendor",
|
|
version: "1.0.0",
|
|
author: {name: "Author"},
|
|
description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"},
|
|
models: ["vendor-model"],
|
|
fetchMode: "per_task",
|
|
routes: [
|
|
{method: "POST", path: "/vendor/v1/jobs", type: "submit", decode: "createJob", render: "jobCreated"},
|
|
{method: "GET", path: "/vendor/v1/jobs/:task_id", type: "query", render: "jobStatus"},
|
|
],
|
|
protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}, "openai_video"],
|
|
};
|
|
```
|
|
|
|
`submit` and `dynamic` routes require `decode` and `render`. `query` routes require `render`, prohibit `decode`, and use `taskIdParam` (default `task_id`). Names refer only to callable members of `native`. Route conflicts use method plus normalized path shape and the route index is published atomically with its plugin generation.
|
|
|
|
`routes[].models` optionally restricts a `submit` or `dynamic` route to a subset of `meta.models`. The host matches the canonical top-level `model` body field before any JS hook runs; a missing, non-string, or unlisted model is rejected with 400 (plugin routes are exclusive paths, so there is no relay fallback). Declare it only when the model lives at the body top level — vendor formats that nest the model elsewhere should omit `models` and filter inside `decode`. `query` routes have no body and reject the field.
|
|
|
|
Each `protocols` entry claims a host protocol. A protocol that defines modes must be claimed in object form with an explicit `supports`; the bare-string form is valid only for protocols without modes (`openai_video`). `supports` declares which client request forms the plugin accepts on `openai_responses`: `"stream"` (`stream: true`), `"sync"` (neither flag; the call blocks until the terminal Response), `"background"` (`background: true`; the create returns a pending Response immediately). An unsupported request form is rejected with a 400 at channel selection, before any plugin hook runs and before billing. Retrieval (`GET /v1/responses/:response_id`) is not a mode: every created response is always retrievable.
|
|
|
|
`{name, models}` still narrows that protocol's endpoint bindings to a subset of `meta.models` and composes with `supports`. Unlisted models never enter the plugin on that protocol path — they fall through to the built-in Go relay. Cross-plugin endpoint conflicts are judged on the narrowed set, so two plugins may share one protocol path by claiming disjoint model sets.
|
|
|
|
Enabled uploads pre-flight the candidate against the live routing generation and reject the first channel-type, native-route, or protocol-model conflict (the error names the counterpart plugin). Set `force: true` or `enabled: false` to store the plugin anyway.
|
|
|
|
`endpoints`, `routes[].renderer`, global `resolveRequest`, global `renderError`, and global `renderers` are rejected. `parseSubmitResponse` returns only `{taskId, taskData, immediate?, state?}`; `clientResponse` is rejected.
|
|
|
|
`icon` is an optional LobeHub icon name string (for example `Sora.Color`). The values `text` and `text:<label>` request a generated text avatar instead (label defaults to the first two characters of `name`). It is display-only and does not participate in routing, billing, or admission beyond type and length checks.
|
|
|
|
| Field | Type | Notes |
|
|
|-------|------|-------|
|
|
| `key` | string | Required. Canonical plugin id, ≤ 30 characters. |
|
|
| `name` | string | Required. Display name. |
|
|
| `icon` | string | Optional LobeHub icon or `text` / `text:<label>`. ≤ 128 characters. |
|
|
| `description` | LocalizedText | Optional plugin summary. See LocalizedText. ≤ 512 runes per locale. |
|
|
| `version` | string | Required semver. |
|
|
| `author` | `{name, url?}` | Required name; `url`, when present, must be an absolute HTTP(S) URL. |
|
|
|
|
`channelTypes` lists the legacy channel types this plugin's driver can drive (for example, sora declares `[55, 1]` because the same OpenAI-type base URL and bearer key serve both chat and video). Every entry equally participates in channel selection, historical `Task.Platform` matching, and the `byChannelType` routing index; the same type value may not appear on two plugins. Third-party plugins normally omit `channelTypes` and live on type-59 "Task Plugin" channels bound by `task_plugin_key`. The previous split identity/compatibility field names are rejected.
|
|
|
|
Numeric `usageSchema` fields declare a host-owned unit of `second`, `count`, `token`, or `credit`. Boolean fields declare `{type: "boolean"}`.
|
|
|
|
`meta.usageExamples` is an optional display-only list of pricing examples. Each `label` is a human-readable spec name. Each `facts` object must be a complete vector over `usageSchema`: every declared key present, no undeclared keys, and values that pass the same validation as usage facts. Plugins whose schema contains a `unit: "token"` number field must declare at least one example. Examples never affect billing. Labels are spec names such as `std · 10s`, not storefront prices.
|
|
|
|
## LocalizedText
|
|
|
|
`meta.description` and each `usageSchema` field `description` accept LocalizedText. A bare string is equivalent to `{en: <string>}`. A map must include a non-empty `en` value. The host normalizes both forms to a map; API responses always emit an object.
|
|
|
|
```js
|
|
description: "Video generation via the vendor API"
|
|
description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"}
|
|
```
|
|
|
|
Rules:
|
|
|
|
- Locale keys match `^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$`. A map has at most 16 locales.
|
|
- Keys are canonicalized to BCP-47 casing (`EN` → `en`, `zh-tw` → `zh-TW`, `zh-hans` → `zh-Hans`); two keys that collide after canonicalization are rejected as duplicates.
|
|
- Each value is trimmed, must be non-empty, and must not contain control characters.
|
|
- `meta.description` is at most 512 runes per locale. Each `usageSchema` field `description` is at most 256 runes per locale.
|
|
- The frontend resolves a locale with exact tag → primary subtag → `en` (for example `zh-TW` → `zh` → `en`).
|
|
- Description copy in any language must not include vendor currency prices. The same prohibition applies to `usageExamples` labels.
|
|
|
|
## Request body
|
|
|
|
Every decoder receives one host-parsed body:
|
|
|
|
```ts
|
|
{kind: "json", value}
|
|
{kind: "form", fields: Record<string, readonly string[]>}
|
|
{kind: "multipart", fields: Record<string, readonly string[]>, files: readonly FileReference[]}
|
|
{kind: "none"}
|
|
```
|
|
|
|
`FileReference` contains `{ref, field, filename, mimeType, size}`. The ref is opaque and request-scoped; file bytes, readers, temporary paths, and a second inbound `requestBody` representation are never exposed to JavaScript. The decoder creates a new canonical `{kind:"submit", model, action?, requestBody?, originTaskIds?}` intent. Driver hooks consume that normalized `requestBody` and remain surface-independent.
|
|
|
|
## Request building
|
|
|
|
`buildSubmitRequest` returns a request descriptor. JSON bodies (the default) may embed a file placeholder at any object or array depth. The host replaces each placeholder with an encoded string before the request is sent; JavaScript never sees file bytes.
|
|
|
|
```js
|
|
{ __fileRef: "request_file:input_reference", encoding: "base64" } // raw Base64
|
|
{ __fileRef: "request_file:input_reference", encoding: "dataUrl" } // data:<mime>;base64,<data>
|
|
{ __fileRef: "request_file:input_reference", encoding: "dataUrl", mimeType: "image/png", maxBytes: 20971520 }
|
|
```
|
|
|
|
Placeholder objects may contain only `__fileRef`, `encoding`, `mimeType`, and `maxBytes`. `encoding` is `base64` or `dataUrl`. `__fileRef` must resolve to an uploaded multipart file (`request_file:<field>`). `mimeType` optionally overrides the part `Content-Type` for `dataUrl` (otherwise the part header, else `application/octet-stream`). `maxBytes` is an optional vendor cap; the host also applies `MAX_FILE_DOWNLOAD_MB` (default 64MB) to each file and to the total inlined bytes. Multipart descriptors still stream files with `parts[].fileRef`.
|
|
|
|
## Origin tasks
|
|
|
|
Decoders normalize vendor references (draft ids, continuation ids, gateway-issued asset references) into `originTaskIds` as public gateway task IDs. The host never parses vendor request bodies.
|
|
|
|
The host verifies ownership, requires every referenced task to belong to the same plugin platform set and one enabled channel, pins that channel for the submit (including retries), and injects the resolved rows into driver hooks as `ctx.originTasks` (`taskId`, `upstreamTaskId`, `action`, `status`, and `data`, including the private upstream id). Renderers and presenters never receive `originTasks`.
|
|
|
|
## Native surface
|
|
|
|
```js
|
|
export const native = {
|
|
createJob(ctx) { return {kind: "submit", model: ctx.body.value.model, requestBody: ctx.body.value}; },
|
|
jobCreated(ctx, task) { return {job_id: task.task_id, state: "queued"}; },
|
|
jobStatus(ctx, task) { return task.data; },
|
|
error(ctx, error) { return {code: error.code, message: error.message}; },
|
|
};
|
|
```
|
|
|
|
The host authenticates, checks ownership, persists the task, builds `TaskView`, and replaces known private task-id fields with the public ID before calling presenters. Native presenters own the vendor envelope. Authenticated native query presenters may intentionally pass through provider URLs from `task.data`.
|
|
|
|
## Error contract
|
|
|
|
Hook `Error` messages are surfaced (truncated and sanitized) to API callers. Plugin authors should write validation failures as user-readable sentences.
|
|
|
|
## Host protocols
|
|
|
|
The host registry owns these bindings:
|
|
|
|
| Protocol | Operation | Binding | Body |
|
|
|--------------------|-----------|---------------------------|-----------------------------------|
|
|
| `openai_responses` | create | `POST /v1/responses` | JSON |
|
|
| `openai_responses` | retrieve | `GET /v1/responses/:response_id` | none |
|
|
| `openai_video` | create | `POST /v1/videos` | JSON or multipart |
|
|
| `openai_video` | retrieve | `GET /v1/videos/:task_id` | none |
|
|
| `openai_video` | content | `GET | HEAD /v1/videos/:task_id/content` | none |
|
|
|
|
`protocols.openai_responses` always requires `decodeRequest`. The remaining hooks are derived from `supports` and verified exactly at load in both directions: `"stream"` requires `renderEvents`; `"sync"` or `"background"` requires `renderFinal`. A hook required by a supported mode but not exported, or an exported hook that no supported mode uses, rejects the plugin. For a plugin supporting only `"stream"`, the host renders retrieval at terminal SUCCESS by calling `renderEvents` once with no previous state — the same first-call-at-terminal semantics streaming already requires. The mode vocabulary is append-only under apiVersion 1: new modes may be added (existing manifests are unaffected; a manifest declaring a mode loads only on gateways that define it), but a mode may never be removed, renamed, or re-mapped to different hooks without an apiVersion bump. `protocols.openai_video` requires `decodeRequest` for create and `render` for retrieve. The host extracts and pins `ctx.model`, normalizes `ctx.stream`, frames SSE, creates failure envelopes, and calls `renderFinal` only for `SUCCESS`.
|
|
|
|
`background: true` on create returns the pending Response immediately (host-synthesized, no plugin hook). Retrieval renders via `renderFinal` only at terminal status. Plugins declaring `"background"` need no create-time hook; the deliverable is rendered at retrieval via `renderFinal`. Plugins cannot observe the `background` field. Unlike the upstream OpenAI API, a plugin that does not declare `"stream"` rejects `stream: true` with a 400; the error names the supported forms.
|
|
|
|
Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from `task.data` are not protocol output. OpenAI Video projections are DTO-whitelisted and the host overwrites identity, lifecycle, timestamps, and removes URL-like metadata.
|
|
|
|
## Persisted data and driver hooks
|
|
|
|
The persisted field remains `task.data`; there is no `task.raw` alias. Driver hooks (`buildSubmitRequest`, `parseSubmitResponse`, query/result, usage, artifact, and content hooks) stay flat and must not branch on the client path or protocol.
|
|
|
|
`ctx.model` is the billing and display identity (the origin name the client sent, including a channel-mapping alias). `ctx.upstreamModel` is the machine identity after channel `model_mapping`. Rate tables and model-keyed usage facts must use `ctx.upstreamModel || ctx.model`. Decode and render hooks that echo the client model must keep `ctx.model`. `buildSubmitRequest` must not set descriptor top-level `model` on a mapped pin; the host requires the plugin to echo the alias verbatim. Background polling has no relay info, so query hooks receive both identities from the persisted task properties, and `ctx.upstreamModel` falls back to `ctx.model` when the task was submitted without a channel mapping.
|
|
|
|
## Polling contract
|
|
|
|
Query and parse hooks use `TaskQueryContext`, not `DriverContext`. The host rebuilds that context from the persisted task row. There is no query-side `requestBody`.
|
|
|
|
| Field | Source |
|
|
|-------|--------|
|
|
| `taskId` | Upstream task id (`PrivateData.UpstreamTaskID`, else `TaskID`) |
|
|
| `publicTaskId` | Gateway task id |
|
|
| `action` | Normalized persisted action |
|
|
| `model` | `Properties.OriginModelName` |
|
|
| `upstreamModel` | `Properties.UpstreamModelName`, falling back to `model` |
|
|
| `baseUrl` / `apiKey` / `authHeader` / `auth` | Channel credentials |
|
|
| `data` | Current `Task.Data` snapshot |
|
|
| `state` | Plugin-owned `PrivateData.PluginState` |
|
|
|
|
`Task.Data` is the latest upstream response snapshot for presenters and artifacts. The host overwrites it on every successful parse. Values that must survive across poll rounds belong in `state`.
|
|
|
|
`parseSubmitResponse`, `parseTaskResult`, and each `parseBatchResult` item may return optional `state`. The host writes it only when the hook returns it. Omitting `state` preserves the previous value. Oversized state is rejected with a warning, not truncated.
|
|
|
|
`buildBatchQueryRequest(ctx, tasks)` and `parseBatchResult` receive `tasks: TaskQueryContext[]`. `parseTaskResult` / `parseBatchResult` also receive `{status, headers}` for the upstream HTTP response.
|
|
|
|
`status: "UNKNOWN"` means the plugin does not recognize the response. Do not write `|| "IN_PROGRESS"` (or equivalent) for a missing table entry. The host treats `UNKNOWN`, hook errors, empty status, and unrecognized status strings as consecutive poll failures.
|
|
|
|
The host classifies the HTTP status before trusting a non-terminal parse:
|
|
|
|
| Upstream HTTP | Host action |
|
|
|---------------|-------------|
|
|
| 2xx | Call the parse hook |
|
|
| 404 / 410 | Immediate `FAILURE` and refund |
|
|
| 401 / 403 | Leave task status unchanged; increment `PollFailures`; `LogWarn` with channel id. Channels are not auto-disabled. |
|
|
| 429 / 5xx / transport error | Increment `PollFailures` |
|
|
| Other 4xx | Call the parse hook with `response.status`. A still-non-terminal result is unrecognized and increments `PollFailures`. |
|
|
|
|
A valid 2xx non-terminal parse resets `PollFailures` to 0. After `TASK_POLL_MAX_FAILURES` (default 20) consecutive failures the task becomes `FAILURE` and follows the existing refund chain. The 24h `TASK_TIMEOUT_MINUTES` sweep remains the outer deadline.
|