Files
new-api-custom/docs/plugin-api/v1.md
T
cneicy a5f7b8e586
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
2026-09-26 04:49:11 +08:00

470 lines
37 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. Multiple plugins may claim the same model on the same host protocol path, including type-61 plugins and plugins with legacy `channelTypes`. Candidates are ordered by ascending plugin key. The selected channel determines the executing plugin: its channel type for legacy channels, or `setting.task_plugin_key` for type-61 channels.
Enabled uploads pre-flight the candidate against the live routing generation and reject channel-type, native-route, or incompatible protocol conflicts (the error names the counterpart plugin). Sharing a model on the same host protocol is allowed. 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`). Inline data URIs and remote URLs are rejected. `icon` is display-only and does not participate in routing or billing.
A plugin whose vendor has no LobeHub icon ships its logo as a sidecar file, `icon.svg` or `icon.png`, next to `plugin.js`; the manifest stays readable and the image never appears in source diffs. Built-in plugins embed the sidecar from `plugins/tasks/<key>/`. For uploads the admin UI reads the file and sends it in the upload request's separate `icon` field as `data:image/png;base64,...` or `data:image/svg+xml;base64,...`; marketplace indexes declare it as `iconFile.path` and the installer fetches it from the index's own origin. The gateway stores the logo apart from the source and serves it from `GET /api/plugin/task/:key/icon` (optionally `?version=`), which the UI renders only through `<img>`. Limits: 512 KiB; PNG must carry the PNG signature; SVG must be well-formed XML with an `svg` root and no `script` or `foreignObject` elements, event-handler attributes, DOCTYPE, `javascript:` values, or absolute `http(s)` references. Supply artwork legible on both light and dark backgrounds (an SVG may use a `prefers-color-scheme` media query).
`baseUrl` is an optional default upstream address for channels of type 61 ("Task Plugin"). When an administrator binds the plugin and leaves the channel Base URL empty, the host copies this value onto the channel before validation and records `base_url_source: plugin_default` in the channel audit event; the persisted channel value is what every later request uses, so a later plugin update that changes `baseUrl` does not move existing channels. The value must be an absolute `http` or `https` URL without credentials, query, or fragment; the host is lowercased and must be ASCII (use punycode), trailing slashes are stripped, and the normalized value must not exceed 191 characters (the width of the channel `base_url` column on MySQL). Private, loopback, and plain `http` addresses are allowed for self-hosted upstreams; the admin UI flags them before binding. `baseUrl` is ignored on legacy `channelTypes` channels, which keep the built-in per-type default. The default host is not implicitly added to `allowedHosts`: if the administrator points the channel at a different host, plugin requests to the author's default host are rejected.
`allowedHosts` lists extra hosts that plugin requests may target besides the channel base URL host. Each entry is `host` or `host:port` (IPv6 literals bracketed, for example `[::1]:8080`); schemes, paths, credentials, and queries are rejected. Entries are lowercased and must be unique after normalization. An entry with a port matches only requests to that port (default ports `80`/`443` match requests that omit them).
| 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. Image logos ship as a sidecar `icon.svg` / `icon.png`, not in the manifest. |
| `description` | LocalizedText | Optional plugin summary. See LocalizedText. ≤ 512 runes per locale. |
| `version` | string | Required semver. |
| `sortPriority` | `number` | Optional signed 32-bit integer, default `0`. Higher values appear first; ties use ascending plugin key. Display only: does not change routing or override precedence. |
| `website` | `string` | Optional plugin website, independent of `author.url` and `baseUrl`. Empty is allowed; otherwise an absolute HTTPS URL with a valid ASCII hostname (use punycode for internationalized domains), without credentials, whitespace, control characters, or backslashes. Paths, queries, and fragments are allowed. |
| `author` | `{name, url?}` | Required name; `url`, when present, must be an absolute HTTP(S) URL. |
| `baseUrl` | string | Optional default Base URL for type-61 Task Plugin channels. Absolute `http(s)` URL, no credentials/query/fragment, ASCII host, trailing slash stripped, ≤ 191 characters. |
| `allowedHosts` | string[] | Optional extra request hosts, `host` or `host:port`. No schemes, paths, or credentials. |
`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-61 "Task Plugin" channels bound by `task_plugin_key`. The previous split identity/compatibility field names are rejected.
Before channel distribution, the host calls every mode-compatible candidate's
`decodeRequest`. Only candidates returning a valid submit intent for the pinned
model remain eligible. If all reject the request, the host returns the candidate
failures, grouping identical messages with their plugin keys. A single candidate
retains its original failure message. After channel selection, the executing plugin decodes again to build
its own normalized request. Decoders must therefore tolerate repeated calls.
Channel-type ownership remains unique; sharing models does not relax this rule.
Shared models may use different usage schemas and prices for each plugin.
Administrators can save a provider expression in
`billing_setting.plugin_billing_expr`, keyed by `<pluginKey>::<model>`.
Without an override, the provider inherits the model-level expression. If the
effective expression references a literal `u()` key absent from that provider's
selected schema, the shared model is unconfigured for that provider; submission
returns 400 `model_price_error` until compatible pricing is saved. Pricing pages
show the provider variants and their configuration status. Model-level usage
labels continue to come from the first plugin in ascending key order. Saving a
model expression checks every candidate without its own override in the same
draft. Provider overrides are not included in model-price synchronization.
Saved overrides remain visible and effective when only one provider is left.
An override whose plugin is unavailable or no longer declares the model is
marked `stale` in the administration snapshot. Its unchanged value can be kept
while saving other prices, or explicitly removed; adding or changing an override
still requires a current plugin/model binding. Public variants distinguish
`ratio` fallback pricing from `tiered_expr`, so a provider using a configured
per-call price is not marked unconfigured merely because it has no expression.
Numeric `usageSchema` fields declare a host-owned unit of `second`, `count`, `token`, or `credit`. Boolean fields declare `{type: "boolean"}`.
Number fields with `unit: "count"` may additionally declare `unitLabel`, an
optional LocalizedText display unit. This distinguishes counted objects without
changing the canonical unit, usage facts, expression keys, prices, or settlement:
```js
image_count: {
type: "number",
unit: "count",
unitLabel: {en: "image", zh: "张", "zh-TW": "張"},
description: {en: "Image generation unit price", zh: "图片生成单价"},
}
```
Labels are short unit names or counting words, such as `image` / `张` or
`song` / `首`, without prices, quantities, slashes, or trailing punctuation.
They follow the same locale normalization, required English fallback, and
256-rune-per-locale limit as field descriptions. A bare string is normalized to
an English map. The UI uses the current language, its primary language, then
English; omitted labels retain the host's existing count-unit display.
Labels are plugin data, not frontend translation keys. They apply equally to
`usageSchema` and `usageProfiles[].schema`, including pricing, calculators,
plugin details, and usage-log pricing. Other units, booleans, and enums cannot
declare `unitLabel`; token prices retain their per-million display scale.
This is an additive extension of plugin API version 1. Updated hosts accept
existing plugins unchanged; older hosts reject the unknown `unitLabel` property.
Upgrade the host before installing a plugin that declares it.
`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.
### Usage profiles by model
`meta.usageProfiles` optionally contains `{models, schema, examples?}` entries.
Each profile provides the complete schema and display examples for its models,
replacing `usageSchema` and `usageExamples` without merging or inheriting them.
Unmatched models use the plugin defaults. Models must use the declared spelling
from `meta.models` and belong to at most one profile. Empty model lists and
missing/null schemas are rejected; an empty schema object is valid. Token-unit
profiles require their own examples. The same field, description, and example
validation applies inside a profile as at the plugin level.
Define constants by use case (for example `IMAGE_USAGE_SCHEMA` and
`VIDEO_USAGE_SCHEMA`) and reference them from the profiles. These are ordinary
JavaScript constants: the host executes the module before reading `meta`.
Static consumers can use the compiled display metadata in the marketplace index.
Model-level pricing and save validation select the declared target of an alias;
ambiguous aliases retain the plugin defaults. Runtime validation selects the
executing plugin's final upstream model, including each persisted task during
batch polling. Existing saved expressions are not migrated. Single-plugin models retain their
existing submission checks; shared models additionally enforce the per-provider
field-reference compatibility rule above. Normal evaluation and quota safety
checks continue to apply. Saving an expression uses its model's selected schema.
This remains plugin API version 1. New hosts accept older plugins unchanged;
older hosts reject the new `usageProfiles` field. Upgrade the host to a build
supporting profiles before installing a plugin that declares them.
## LocalizedText
`meta.description` and each `usageSchema` field `description` and `unitLabel` 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.
Enum fields may additionally declare `enumLabels`, a map from enum values to LocalizedText display names:
```js
video_input: {
enum: ["none", "video"],
description: {en: "Reference video input", zh: "参考视频输入"},
enumLabels: {
none: {en: "No reference video", zh: "无参考视频"},
video: {en: "With reference video", zh: "有参考视频"},
},
}
```
`enumLabels` is optional and may cover only some options. It is valid only on enum fields, and every key must exactly match a declared enum value. Each label follows the same locale normalization, required English fallback and 256-rune-per-locale limit as a field description. Labels are short phrases without trailing punctuation. They never change usage facts, expression conditions or values submitted by UI controls. Boolean fields use their description with a localized yes/no state and cannot declare `enumLabels`.
The UI selects the current language, then its primary language, then English. If no option label is provided, it displays the original enum value; a missing field description falls back to the field name. Labels are plugin data, not frontend translation keys.
This is an additive extension of `apiVersion: 1`. Updated gateways continue to load plugins without `enumLabels`; older gateways with strict property validation reject plugins that declare it.
```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.
### Description writing and translation conventions
Descriptions are short, user-facing phrases without trailing punctuation in any language. Keep translations equivalent in meaning. For `usageSchema` fields, use the following wording:
| Field meaning | Wording | English example | Chinese example |
| --- | --- | --- | --- |
| Numeric billing quantity | Billing subject + unit price | Song generation unit price | 生成歌曲单价 |
| Action | Action phrase | Generate songs | 生成歌曲 |
| Boolean | Whether a state is enabled or something is present | Whether audio is generated / Reference video present | 是否生成音频 / 存在参考视频 |
| Other enum condition | Short condition name | Output video resolution | 输出视频分辨率 |
- A shared description must cover all supported actions. For Suno, `clips.description` is `Song or lyrics generation unit price` / `生成歌曲或歌词单价`, and `action.description` is `Generate songs or lyrics` / `生成歌曲或歌词`.
- Boolean descriptions name the affirmative state represented by `true`; do not add a question mark or invert the meaning of the field.
- Preserve distinctions such as input versus output. Units belong in `unit`; do not repeat unit explanations or numeric prices in the description.
- Keep protocol limits, usage sources, estimation and settlement details in code comments or technical documentation, rather than in display descriptions.
- For example, replace `Requested video duration in seconds.` / `请求的视频时长,单位为秒。` with `Video generation unit price` / `视频生成单价`; replace `Whether audio is generated. Default true.` / `是否生成音频。默认为 true。` with `Whether audio is generated` / `是否生成音频`.
- These are authoring conventions for plugin metadata, not additional runtime validation rules. Existing `LocalizedText` wire types, usage quantities, enum values and billing semantics are unchanged; a numeric field still contains usage, not a price.
## Host utilities and capabilities
`utils` exposes synchronous host tools. Existing utilities are `unixNow`, `uuid`,
`hmacSHA256`, `jwtSignHS256`, `base64`, `base64URL`, `base64URLDecode`, and
`volcSignV4`; their signatures are declared in `v1.d.ts`.
Plugins using new APIs declare exact versioned capabilities:
```js
requiredCapabilities: ["json-clone@1", "submit-sse-delta@1"]
```
The host rejects unknown, duplicate, or unsupported capability versions at load.
Omitting the field preserves existing behavior. `utils.hasCapability(name)`
supports optional feature detection on hosts that provide this utility; it
does not grant a declared capability. These are additive API v1 extensions;
older strict hosts reject `requiredCapabilities`, so upgrade the host first.
`json-clone@1` provides `utils.json.clone(value)`: an independent JSON snapshot
with native mutable JavaScript objects and arrays. It accepts plain objects,
arrays, null, booleans, finite numbers and strings. Undefined, functions,
BigInt, dates, sparse arrays and cyclic structures are rejected. Object keys
such as `__proto__` remain ordinary own data properties. The tool uses the
host's JSON number and string normalization and preserves explicit zero and
false values. Limits are 1 MiB of encoded JSON, 32 levels below the root and
32,768 total nodes, including containers and scalar values. It performs no I/O.
Host functions are synchronous and must bound their own work: the JavaScript
execution interrupt cannot preempt a running Go function. Request-scoped data
must remain owned by the host request, not by pooled runtime globals.
## 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.
Responses protocol media uses host-injected `ctx.artifacts[key].url`. OpenAI Video renderers may return provider fields and URLs from `task.data`; the host preserves those extensions and metadata, but overwrites `id`, `object`, `model`, `status`, `progress`, `created_at`, and `completed_at` and removes legacy `task_id`. Renderers must return a JSON object. Returning `task.data` exposes the latest persisted upstream snapshot, subject to the host's task-ID replacement and inline-video redaction; it does not perform a fresh upstream query or forward HTTP headers/status codes.
## 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.
Marketplace index v1 plugin entries also accept optional `sortPriority` and `website` fields. Each source is sorted independently by descending priority and ascending key. Missing or invalid priorities fall back to `0`; invalid websites are hidden. Installed plugins and channel binding options use plugin metadata. Website links open only on user interaction; the host does not fetch them.
## Immediate completion and submission streams
A driver may return `immediate: {status: "SUCCESS" | "FAILURE", ...}` from
`parseSubmitResponse`. For immediate success the host calls the existing
`extractUsageOnComplete(ctx, result, taskData)` with a `TaskQueryContext`, using
both model identities, the submitted task ID, parsed data and returned plugin
state. The same model-specific usage validation as polling applies. Plugins
return usage quantities, never host quota or a charge.
For task expressions the host overlays valid completion facts onto the frozen
submission facts, evaluates the frozen expression and reserves any increase
before persistence. The final quota, usage and tier are recorded before the
single BillingSession settlement. Zero usage is retained. Invalid facts or an
expression evaluation failure retain the reservation and produce a warning.
Immediate failure settles to zero. Terminal tasks are not polled. Legacy
per-call pricing keeps its existing meaning and submission-ratio hook.
### Optional SSE response reader
`meta.submitResponseTypes` defaults to `["json"]`. A plugin that needs SSE
includes `"sse"` and exports `parseSubmitEvent`. `buildSubmitRequest` selects
`responseType: "sse"` on its request descriptor (default `"json"`); the selected
type must be declared. This optional v1 manifest field requires a supporting
host: older strict hosts reject it instead of silently misreading the stream.
```ts
parseSubmitEvent(ctx: DriverContext,
event: {event: string; id: string; data: string},
previousState: JSONValue | null
): {state: JSONValue; done: boolean}
```
The host accepts `text/event-stream`, joins multiple `data:` lines with newlines,
handles CRLF, comment heartbeats, event names (default `message`) and event IDs.
It does not parse provider JSON, interpret provider completion markers or sum
usage. The plugin does those operations and returns explicit JSON state on every
call; pooled runtimes do not guarantee module globals survive between events.
Treat `previousState` as read-only JSON input. Construct owned arrays/objects
when accumulating, rather than mutating host-backed arrays in place.
Idle runtimes survive garbage collection in a pool bounded by the plugin's
configured concurrency. The host creates additional instances on demand and
discards interrupted instances. Cancellation callbacks finish before an instance
is returned to the pool. Hook bindings remain live; the host does not freeze
exported functions or object members. No JavaScript execution slot is held while
waiting for the next upstream event.
The host may copy plain JSON containers without re-encoding their string values;
hook inputs and successive states remain separate snapshots. State size is still
checked against its actual encoded JSON bytes after every event, including
intermediate state that a later event might replace with a smaller value.
A complete, blank-line-delimited event whose hook returns `done: true` terminates
the reader and closes the upstream response. The last state is passed as
`response.body` to the existing `parseSubmitResponse`, retaining the upstream
HTTP status and headers. The normal immediate-completion path then persists,
settles and presents the result. SSE state is request-local; durable plugin
state is still only the `state` returned by `parseSubmitResponse`.
EOF without `done`, invalid framing/state, read failures, hook failures and size
limits fail submission. Events, accumulated JSON state and persisted task data
are each limited to 1 MiB; data is never truncated. The existing streaming idle
timeout applies (30 seconds if unset), as do the submission context deadline
and per-hook execution timeout. Once a successful SSE HTTP response is accepted,
failures are non-retryable, including 5xx-shaped local errors: starting the
provider operation again could duplicate billable work.
No upstream events are forwarded to clients. Native presenters still return
JSON, and Responses uses its existing presentation flow after the durable
submission barrier. Native cancellation closes the upstream reader; Responses
retains its existing independently bounded submission context. This interface
does not add task creation before upstream completion or stream resumption.
### Optional host-managed SSE accumulation
Declaring `submit-sse-delta@1` requires `submitResponseTypes` to include `sse`
and selects `parseSubmitEventDelta` instead of `parseSubmitEvent`. The descriptor
still uses `responseType: "sse"`. Existing plugins without this capability keep
the snapshot hook and its original contract.
```ts
parseSubmitEventDelta(ctx: DriverContext,
event: {event: string; id: string; data: string},
previousState: JSONValue | null
): {changes: JSONChange[]; state: JSONValue; done: boolean}
```
`state` contains control data needed to interpret the next event, limited to
64 KiB encoded JSON. Only this snapshot is passed back to JavaScript. The host
maintains a separate result, initially null, and applies `changes` in order:
```js
{op: "set", path: [], value: {text: "", images: []}}
{op: "appendText", path: ["text"], value: "A new text fragment"}
{op: "append", path: ["images"], value: {url: "https://cdn.example/image.png"}}
```
- `set` replaces the root at an empty path, replaces an existing array element,
or adds/replaces an object member. Parent containers must already exist.
- `append` appends one JSON value to an existing array.
- `appendText` appends a string to an existing string. The host retains text in
Go and does not send accumulated text back to the plugin.
- Paths are arrays of string object keys or non-negative integer array indices.
Indices must address existing elements; sparse arrays and implicit parent
creation are unsupported. Keys are data, never property-access expressions.
Each result contains exactly `changes`, `state`, and boolean `done`. Each change
contains exactly `op`, `path`, and `value`. There are at most 256 changes per
event, 32 path segments, 32 levels below the result root, 32,768 result nodes,
and 1 MiB of encoded result JSON. Size accounting includes escaped keys and
strings, punctuation and replacements, and is checked after every operation.
An oversized intermediate operation fails even if a later operation would
shrink the result. Invalid operations invalidate the submission; partial
results are never published.
The host counts the encoded sizes of changed values rather than encoding the
complete accumulated result on every event. At `done: true`, it materializes the
ordinary JSON result and verifies its actual encoding against the recorded
size before passing it as `response.body` to `parseSubmitResponse`. The result
then follows the same persistence, usage validation and settlement path as the
snapshot mode. State and builders belong to one request and are released on
completion, cancellation or error; no JS slot is held during upstream I/O.
The plugin still interprets vendor payloads, decides which fields to update,
distinguishes cumulative usage from increments, and decides completion. Alibaba
uses this mode for interleaved image generation, keeping only content indices,
text adjacency and finish reasons in its control state.