When a task plugin claimed a model on a shared host endpoint
(/v1/images/generations, /v1/images/edits, /v1/responses, /v1/videos),
every request for that model went to the plugin, even when an ordinary
OpenAI-compatible channel served the model too. Ordinary channels and
the matching plugin channels now compete for the request under the usual
group, priority, weight, affinity and channel-pin rules. When an
ordinary channel wins, the original request reaches the ordinary relay
unchanged: no plugin decoder, mode check or model rewrite runs, and
retries stay on ordinary channels. When a plugin channel wins, the
plugin handles the request as before and retries stay on its accepted
plugin candidates. Native plugin routes still belong to their plugins.
Ordinary and plugin prices are kept apart. Task expressions return USD,
while ordinary expressions keep their USD-per-million result, so an
ordinary request now fails with 400 model_price_error before any quota
is reserved when the model's expression reads task usage (u()) or is
still inherited by a task plugin on that endpoint. The model pricing
editor can set ordinary OpenAI prices, per returned image with
fixed(...) * image_count, while each plugin keeps its price in its own
override. A model served by a single plugin now shows that plugin's
pricing editor as well.
Before billing, the gateway walked the whole decoded request of a task
plugin and rejected duration, seconds and count members that were out
of range at any depth, including vendor parameters passed through
metadata. A plugin had to declare duration-auto@1 for the gateway to let
a duration of -1 through.
The gateway no longer reads billing quantities from the request. It
still bounds every usage fact a plugin returns before quota is
calculated: second facts at most 3600, count facts at most 128, token and
credit facts saturated at the quota limit. Each plugin validates its own
request and derives its facts from the request it sends upstream.
duration-auto@1 is still accepted and has no effect.
Kling now bills the duration it sends. metadata.duration, which is the
whole body on native routes, overrides the top-level duration and must be
a whole number of seconds from 1 to 3600. Before, a metadata duration of
10 was sent upstream while 5 seconds were billed.
Driver hooks saw only the Content-Type and Accept headers of the client
request. ctx.requestHeaders now carries every client header, with the
first value under its canonical name, except the headers that carry
gateway credentials: Authorization, Proxy-Authorization, Cookie,
X-Api-Key, X-Goog-Api-Key and Mj-Api-Secret. The gateway still forwards
none of them upstream; a driver copies only the headers its vendor
defines into its request.
Native submit and dynamic routes of task plugins accepted only JSON and
answered 415 to a multipart upload before the plugin saw it. They now
pass multipart bodies to the plugin's decoder, which rejects the body
kinds its vendor format does not use; the built-in plugins keep requiring
JSON. A route that declares models still takes JSON only, because the
gateway reads the model from the JSON body. URL-encoded forms are still
refused.
Multipart text fields no longer have their own 1 MiB limit. Like a JSON
body, they are bounded by the request body limit, so a JSON request sent
as a form field can be as large as the same request sent as the body.
Reuse the channel base host and plugin allowedHosts for redirected query
credentials. Keep anonymous redirects working, prevent credentials from
returning after an untrusted hop, and restrict body replay to trusted hosts.
Preserve SSRF and redirect limits, reject HTTPS downgrades, and redact signed
URLs from redirect-policy errors. Document the existing allowedHosts contract.
Validation: go test ./relay/channel/task/jsplugin -count=1
Passed against clean 6347fb0aa plus this patch, without an overlay.
Covers single/batch polling, 301/302/303/307/308, host and port boundaries,
multi-hop credentials, SSRF, downgrade rejection, and real HTTP receivers.
Related OWASP ASVS v5.0.0 controls: 14.2.3, 1.3.6, 12.3.1, 16.2.5.
https://github.com/OWASP/ASVS/tree/v5.0.0/5.0/en
* fix(task): honor channel header overrides for submission and polling
Apply the existing Request Header Override configuration after task plugin
headers, including per-task, batch and realtime queries. Resolve api_key
from the credential used for that request and skip client-only placeholders
when polling has no original client request. Reuse the HTTP header resolver
without changing plugin contracts or adding provider-specific settings.
Keep polling redirects visible, matching submission and utils.fetch, so a
custom credential header cannot be forwarded to a redirect destination.
Document configuration and cover precedence, saved keys, absent client
context, invalid values, redirects and all polling initialization paths.
Validation with Go 1.26.4 and GOWORK=off:
- make test: root, relaykit and tokenkit passed before the realtime follow-up
- go test ./relay ./relay/channel ./relay/channel/task/jsplugin ./service -count=1: passed after the follow-up
- go vet ./... and go build ./...: passed after the follow-up
- Independent read-only review: passed
Scoped security review used OWASP ASVS 5.0.0 and the Authentication,
Session Management and REST Security cheat sheets. Credentials remain in
headers, client headers are not persisted, and redirects are not followed.
No live provider calls or production deployment were performed.
https://owasp.org/www-project-application-security-verification-standard/https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.htmlhttps://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.htmlhttps://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html
* fix(task): follow poll redirects and override content request headers
Polling follows redirects again through the shared HTTP client, which
checks every hop against the SSRF protection settings. Refusing them
turned each poll of a redirecting upstream into a failure, and after
enough failures the task was failed and refunded.
Credentialed content requests now carry the channel header overrides,
as submission and polling do. Credentialless links, utils.fetch and the
Host header stay as the plugin builds them.
* fix(task): settle tasks that a realtime query sees finish
Fetching a Gemini or Vertex task queries the upstream directly. When
that query saw the task finish, it wrote the final status itself, so
the poller skipped the task and never settled or refunded it: a failed
task kept its charge. Hand that transition to the poller's single-task
update, which records the status and settles exactly once.
---------
Co-authored-by: CaIon <i@caion.me>
utils.json.clone is available to every plugin, and a plugin that exports
parseSubmitEventDelta without parseSubmitEvent now gets the delta hook
without declaring anything. Both capability strings stay accepted for
plugins written for earlier hosts, and a plugin exporting both hooks keeps
the snapshot hook unless it declares submit-sse-delta@1. alibaba drops both
declarations.
utils.fetch is a synchronous, read-only GET or HEAD to the channel Base URL
host or the plugin's allowedHosts, through the channel proxy used for
polling. It is available in buildSubmitRequest, the query and batch build
and parse hooks, and buildContentRequest; every other hook throws. Each hook
call gets at most 4 fetches, 15 s per fetch, 30 s in total and 1 MiB per
body. Redirects are returned, not followed. responseType "bytes" returns an
ArrayBuffer for binary data such as a media file header.
While a fetch waits, the runtime gives up its execution slot and the
watchdog pauses. BuildContentRequest now receives the request context, and
the media proxy shares the plugin request header rules.
A plugin that declares duration-auto@1 may receive a duration of -1 (the
vendor picks the length) anywhere in the request; other negative values and
values above the limit are still rejected, and usage facts stay
non-negative. A declaring plugin reserves the model's longest duration and
settles on the usage the vendor reports.
- doubao 1.3.0 declares it, forwards -1 to Ark and reserves each model's
longest duration (12 s for Seedance 1.0 and 1.5 pro, 15 s for 2.0, 30 s for
2.5). A JSON duration on /v1/videos now reaches Ark.
- alibaba 1.4.2 declares it, so wan3.0 smart duration also works on
/v1/video/generations.
Fixes#7700
The host decoded plugin request bodies into Go maps, so hooks saw their
members sorted and the upstream body was written sorted. TypeSafe Jev
reads the state object as written, and the reordering changed its scores
against calling TypeSafe directly.
A plugin that declares the json-order@1 capability now gets the client's
order end to end: its decoders receive the JSON body as text, its decoded
requestBody is kept as JSON.stringify text for the driver hooks, and its
request descriptors are decoded with Unmarshal so the body reaches the
upstream as that text. Other plugins keep the Go-map path unchanged, and
billing bounds still validate the decoded requestBody.
RawJSON hook arguments, top-level or nested, are now parsed in place with
ParseJSONString instead of being copied first; every caller passes bytes
it never changes afterwards.
Task plugins now run on github.com/Calcium-Ion/moejs v0.1.0-alpha.3
instead of Sobek, and the host utils are ported to it. The plugin API is
unchanged. Code compiled from strings is no longer allowed: eval and the
Function constructors throw an EvalError.
Hooks receive host values without defensive copies. The engine converts
an object or array when the hook first reads it and never writes
JavaScript changes back to the host value. Stored protocol state and task
views are passed as raw JSON and parsed inside the runtime instead of
being decoded into Go maps first. An idle runtime drops its references to
the last call's arguments.
Whether an optional hook is exported is decided once when the plugin
loads, so checking for it no longer takes an execution slot.
Usage validation returns the normalized facts in a new map instead of
writing into the hook result, so a hook that returns the request body no
longer changes it. The request descriptor is copied instead of being
round-tripped through JSON.
An unchanged model expression or provider override of a single-plugin
model stays saveable after the plugin drops a fact from its profile, so
a full pricing save is no longer blocked by existing prices. Shared
models still follow each provider's profile.
The task pricing table reads a dropped fact at the value a plugin keeps
reporting: price terms on dropped counts are omitted, tiers requiring a
dropped boolean to be true are skipped, and other conditions on dropped
facts are removed with the first matching tier kept. Existing prices
open in the table with only the remaining fields.
The submit relay treated every status other than 200 as an upstream
failure, so a task API that answers 201 Created or 202 Accepted (for
example Tencent's wand-vega-video-1.0-lite) returned fail_to_fetch_task
and parseSubmitResponse never ran. The check predates the plugin system,
when every built-in adaptor answered 200; the plugin adaptor already
passes the exact statusCode to parseSubmitResponse and polling has
always classified 2xx as a successful response.
Accept any 2xx before parsing and keep failing other statuses with the
upstream status and body. Add a relay regression that drives a plugin
submission against a real upstream returning 200/201/202 and 400/502,
and document the submit-side 2xx contract in the plugin API.
Fixes#7478
Add the synchronous openai_image host protocol so a task plugin can claim
POST /v1/images/generations and /v1/images/edits; the host submits the
task, polls it inside the request and lets the plugin render the OpenAI
image response. Unclaimed models still fall back to the HTTP image relay.
- Migrate Ali (Bailian) image models to the alibaba plugin and remove the
ali channel image/wan adaptors and the qwen model setting
- Add Doubao Seedream image generation to the doubao plugin
- Support retainResult on routes and indexed multipart file references
- Settle image counts from upstream usage or image payloads; document the
rule in the billing conventions and plugin API docs
- Show billing source and task artifacts in usage logs; rename the
DoubaoVideo channel display name to Doubao and refresh the favicon
A New API (type 60) channel proxies another gateway that may host many
task plugins, so the channel setting gains task_extend_plugin_keys; the
single task_plugin_key stays valid there as well. Channel selection,
shared-model candidate rebinding, the task_plugin.bind permission gate,
plugin usage counting and the disable cascade (which now unbinds gateway
channels instead of disabling them) all honour the union of both fields,
and plugins may no longer claim channel type 60 in channelTypes.
Plugins declare meta.upstreams and the host injects ctx.upstream into
every driver hook context. On a type-60 channel the driver addresses its
own prefixed native routes (doubao, alibaba, kling, jimeng) and the host
sends the channel key as a Bearer header for every auth type; polling
threads the channel type through ChannelMeta and tasks keep the key
that submitted them. Only plugins declaring new_api can be bound.
The channel form offers an "Upstream task plugins" selector for type-60
channels, MultiSelect options can carry an icon, and the plugin API docs,
type declarations and schema describe the additive apiVersion 1
extension.
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.
* feat(plugin): add MiniMax-H3 /v2 video generation to the hailuo task plugin
MiniMax-H3 speaks a different contract from the other Hailuo models, so the
hailuo task plugin now branches on the upstream model instead of adding a Go
adaptor:
- submit builds /v2/video_generation with a multimodal `content` array
(text, first/last frame images, reference video/audio, or a full
`metadata.content` passthrough), an explicit `ratio`, and 768P/2K
resolutions; `metadata.callback_url` and `metadata.aigc_watermark` pass
through
- query uses /v2/query/video_generation/{task_id} and parses the
`{"task": {...}}` envelope, falling back to the /v1 shapes for every other
model
- the /v2 result is a public CDN URL, so its artifact is proxied
credentialless instead of through /v1/files/download
- request bounds (duration 4-15, resolution 768P/2K, ratio whitelist, at most
2 frame images and 9/3/3 reference images/videos/audios) are enforced while
the request body is built, which the host runs during validation, so an
out-of-range duration is rejected with a 400 before it can become a billing
multiplier
- duration and resolution are reported as usage facts only. Like the rest of
this plugin, extractUsage returns no billing ratios, so per-call pricing is
flat and 2K/duration pricing is expressed through the model's tiered billing
expression over those facts.
Query hooks are driver hooks and are documented to receive `ctx.model` and
`ctx.upstreamModel`, but polling has no relay info and never populated them.
The polling and realtime-fetch call sites now carry the persisted task model
properties and the plugin adaptor maps them onto the query context, with
`upstreamModel` falling back to the origin name for tasks submitted without a
channel mapping.
* fix(plugin): validate Hailuo H3 requests and errors
Channel model_mapping keys exposed in a channel's model list now act as
first-class aliases for task-plugin models across the whole line:
- Derived alias view (model/task_model_alias.go): built from enabled
channels' model_mapping, chain-following with cycle detection, declared
names always win, cross-plugin conflicts dropped. Rebuilt on channel
cache refresh, registry generation change, and a 60s TTL.
- Request path: PinTaskPluginEndpoint resolves declared-name case folds
and mapping aliases before endpoint lookup (never rewriting the body
until the endpoint is claimed), pins with MappedModel, and the decode
contract accepts alias echoes without loosening model ownership for
normal pins. Legacy /v1/tasks submit folds case variants the same way.
Fixes aliases on POST /v1/responses silently falling through to the
main relay against task channels.
- Mapping order: ModelMappedHelper now runs before the plugin submit
hook builds and caches the upstream body, so channel model_mapping
actually reaches the upstream request. Plugins receive the mapped
name as ctx.upstreamModel in both decode and submit contexts.
- Billing: identity stays the origin name; when the alias has no tiered
expression, the selected channel's mapping tail expression applies.
Pricing page and billing-expr smoke tests resolve aliases to the
owning plugin's usage schema.
- Case folding: ASCII-only fold with exact-match priority; same-plugin
and cross-plugin fold collisions rejected at registration.
- Plugins: model-keyed rate tables, req_key derivation, and combo
validation in doubao/kling/jimeng/hailuo/vidu/sunoapi now key on
ctx.upstreamModel || ctx.model; render/echo paths keep ctx.model.