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.
A plugin may also, or instead, relay ordinary API requests on a type-61 channel and manage that channel's credentials. See Provider part.
A plugin may also, or instead, inspect and change ordinary API requests on their way upstream, on any channel. See Request processing part.
Protocol names lists the request formats hooks see, by the same names on every host. Host-specific members lists what only a gateway with channels, user groups and several users, such as new-api, provides; a plugin meant for other hosts too checks those members before using them.
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): the path parameter of that name, or, when the path has none, the query-string parameter of that name, as MiniMax's GET /v1/query/video_generation?task_id= reads it. Names refer only to callable members of native. A plugin's routes, with the /{key} addresses below, must differ in method or normalized path shape; the route index is published atomically with its plugin generation.
A plugin declares its vendor's own paths. The gateway serves each route at that path and again under /{key}: doubao's POST /api/v3/contents/generations/tasks is also POST /doubao/api/v3/contents/generations/tasks, which reaches doubao alone. A route whose first segment is the key, or a proper prefix of it, is already the plugin's own address and gets no copy: alibaba's /ali/..., sunoapi's /suno/..., /hailuo/... for hailuo. The own prefixes of the built-in plugins are protected: another plugin may not take one as its key, and its top-level routes under one are not served.
Several plugins may declare the same method and path shape, for the same model as well. Every plugin declaring a submit path decodes the request, in ascending key order; the first that accepts it names the model, the others that accept the same model stay candidates, and the selected channel decides which plugin executes, as on a shared host protocol, billing the task at that plugin's <pluginKey>::<model> expression. A decoder's model is matched as on the host protocols: a case variant stands for the declared model and the task takes the declared spelling; an alias that an enabled channel lists and maps through model_mapping stays the task's model, and the driver reads the mapped name from ctx.upstreamModel. A single candidate's rejection reaches the client as it is; when all of several reject, the client gets each message prefixed with the plugin keys that gave it. A query path answers through the task's own plugin, the first in key order owning it; a task none of them owns is not found, like a missing one. A dynamic path answers a query through the first plugin owning the tasks and otherwise continues as a submit. Each plugin reads the path parameters under the names it declares, so /v1/videos/:request_id and /v1/videos/:task_id share a path. A catch-all (*name) beside another plugin's literal segment at the same position cannot be served together; the gateway then excludes one plugin with a routing error.
Routes stay out of the reserved namespaces /v1/tasks, /assets and the console pages, and a key may not be api or the first segment of one of them: such a plugin fails to compile. Every plugin, built-in or uploaded, is served at its top-level paths, such as POST /v1/systemone, except where a host route of the same method, or the trailing-slash redirect it causes, answers first, or where the first segment is another plugin's key or a built-in plugin's own prefix. Such a route is still served under /{key}, and the plugin list (GET /api/plugin/task) and detail name it in skipped_routes ("GET /v1/models"). A plugin whose key is api, the first segment of a host route (v1, v1beta, mj, pg, dashboard) or a built-in plugin's own prefix is excluded with a routing error. A query route's task-id parameter only receives gateway task ids, so it never meets a literal host segment: GET /v1/videos/text2video/:task_id is free beside the host's GET /v1/videos/:task_id/content. Two host routes offer matching requests to plugin routes first, so plugin routes may overlap them. GET /v1/videos/:task_id answers through the task's own plugin when the task was submitted on one of that plugin's native routes (at the vendor path, under /{key} or under its own prefix) and the plugin serves a query route matching the request; every other task gets the OpenAI video object. POST /v1beta/models/{model}:predictLongRunning goes to the matching plugin route before the Gemini relay's authentication, rate limit and channel selection; other Gemini actions, and requests no plugin route matches, stay with the relay.
routes[].retainResult is an optional boolean on submit and dynamic routes, default true. Declare retainResult: false only on routes where every successful submission completes immediately (parseSubmitResponse returns a terminal immediate), typically a vendor's synchronous image API that has no task to re-query. For such a result the host does not persist task.data, and the task is thereafter not found on every retrieval surface: the plugin's own query routes, GET /v1/responses/:response_id, GET /v1/videos/:task_id and its content, and artifact listing and content. The task row remains for billing and the task log. The submit presenter still receives the complete in-memory task.data, and extractUsageOnComplete runs before persistence, so settlement is unchanged. Because nothing is persisted, the submit reply of such a route, each of its SSE events and its in-memory task.data may be as large as MAX_FILE_DOWNLOAD_MB (default 64 MB) instead of 1 MiB, which lets a vendor answer with Base64 images; plugin state keeps its 1 MiB limit. An asynchronous result on a declaring route is retained and retrievable as usual and logs a warning. query routes reject the field. openai_responses and openai_video always retain their results because their deliverables are served through host artifact URLs; a plugin that wants previews with less storage should instead return a compact taskData from parseSubmitResponse. openai_image delivers its images inline in the create response, so an immediate terminal result on that protocol is discarded exactly as with retainResult: false, while a result completed by in-request polling keeps the snapshot the poller wrote. This is an additive apiVersion 1 extension: older hosts reject the unknown field.
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, counting a case variant or a channel alias of a listed model as listed; 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, openai_image). 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, setting.task_plugin_key for type-61 channels, or the bound entry of setting.task_plugin_key / setting.task_extend_plugin_keys for type-60 New API channels. A type-60 channel is extended with any number of plugins because the upstream gateway may host many; when several bound plugins share the model, the first bound candidate in ascending key order executes, independent of which candidate an earlier attempt pinned.
Every change that puts a plugin into service pre-flights that plugin against the live routing generation: an upload with enabled: true (the default), switching a plugin on, activating a version (activation also switches it on), deleting the active version when another stored version or the factory built-in takes over the key, and uploading the serving version again with enabled: false when the factory built-in takes over. A channel type another plugin claims, a model name that differs from another plugin's only in case, or another protocol on the same host path and model rejects the change and leaves everything as it was (the error names the counterpart plugin); force does not bypass the check. Sharing a model on the same host protocol, or a native path, is allowed. An upload with enabled: false still stores a conflicting plugin, and switching it on is rejected while its counterpart is enabled. To replace a plugin, switch the old one off first.
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. These are trusted credential recipients for both direct requests and task query redirects; add only hosts permitted to receive the channel credentials. The request processing hooks have no channel: for them, allowedHosts and the instance's url config values are the hosts utils.fetch may address (see Host utilities). 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).
upstreams lists the kinds of upstream the driver's request builders can address. vendor is the provider API itself and is implied for every plugin; new_api is another New API gateway that has the same plugin installed. Only a plugin that declares new_api can be bound to a type-60 New API channel: the host rejects other bindings when the channel is saved, and the channel form lists only declaring plugins. A gateway serves a plugin's wire format only on that plugin's own native routes, and it authenticates native routes and host protocols with Authorization: Bearer <token>. The native routes of two built-in plugins also take the token in their vendor's own form: vidu's as Authorization: Token <token>, google's as x-goog-api-key: <token> or ?key=<token>. Prefer the header: a query string can reach the access logs of proxies in front of the gateway. The gateway takes key out of the URL before any plugin hook sees it and leaves it out of its own request log. An Authorization header the client sends wins over both Gemini forms. Kling's AK/SK JWT, Vertex AI OAuth access tokens and Volcengine V4 signatures are not accepted. The built-in plugins declare their vendors' official paths, which the gateway also serves under /{key} (/doubao/api/v3/..., /kling/v1/videos/...); alibaba and sunoapi also keep /ali/... and /suno/.... Earlier gateway releases serve the built-in plugins only under their prefixes. The host therefore tells the driver which kind of upstream the executing channel is through ctx.upstream (see Persisted data and driver hooks) instead of guessing a prefix from meta.routes; a plugin whose vendor paths already equal its native routes (sunoapi) or that only uses host protocols (sora) declares new_api without changing any URL. Values must be unique and drawn from the two kinds above; listing vendor is optional and descriptive. This is an additive apiVersion 1 extension: older hosts reject the unknown field, and a declaring plugin keeps building vendor URLs on hosts that never set ctx.upstream.
| Field | Type | Notes |
|---|---|---|
key |
string | Required. Canonical plugin id, ≤ 30 characters. |
name |
string | LocalizedText | Required. Display name. A locale object must include en and gives at most 128 runes per locale; the host uses its en text as the name and sends the object as name_i18n next to it. Older hosts accept only a string. |
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. |
upstreams |
("vendor" | "new_api")[] |
Optional upstream kinds the driver addresses. vendor is implied; new_api makes the plugin bindable to type-60 New API channels. |
relay |
route[] | Optional relay routes of the provider part. See Provider part. |
optional |
boolean | Optional, default false. A request skips a failed instance of the plugin instead of stopping. Only a plugin with a request processing part may declare it. See Failures under Request processing part. |
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, and neither the Task Plugin (61) nor the New API (60) type may be declared. Third-party plugins normally omit channelTypes and live on type-61 "Task Plugin" channels bound by task_plugin_key, or are listed in a type-60 New API channel's task_extend_plugin_keys without claiming a channel type. The previous split identity/compatibility field names are rejected.
On shared host endpoints, ordinary relay channels and matching plugin channels compete under the usual group, priority, weight, affinity, and channel-pin rules. A plugin's model declaration does not exclude an ordinary OpenAI-compatible channel serving the same model. When an ordinary channel wins, the host keeps the original request body and skips plugin mode checks, decoders, and model rewrites. Retries remain on ordinary channels for that endpoint; plugin retries remain on its accepted plugin candidates. Plugin-owned native routes keep their exclusive routing.
When a plugin channel wins initial selection, before final 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.
When the same model also uses ordinary relay channels, keep ordinary pricing at
the model level and task pricing in plugin overrides. Ordinary relay expressions
cannot call u(); use fixed(...) * image_count for per-image USD prices.
Task expressions return USD directly, while ordinary expressions retain their
USD-times-one-million result contract. An ordinary request fails with 400
model_price_error before reservation or upstream submission if it encounters a
usage expression, or an expression still inherited by an endpoint's task plugin.
Expressions containing fixed() are unambiguously ordinary pricing, since task
usage billing already rejects them. Existing task expressions are never rewritten
or converted automatically.
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:
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.
The host bounds every usage fact before it calculates quota and does not read billing quantities from the request. Numeric facts must be finite and non-negative, second facts at most 3600 and count facts at most 128; token and credit facts saturate at 2,147,483,647. Submission facts that break these rules fail the request with 400 before billing. The plugin validates its own request: it derives its facts from the request it sends upstream, including values it merges from metadata or other passthrough maps, and bounds any request quantity it turns into a token or credit fact, because the host only saturates those. A plugin that forwards a duration of -1, which some vendors read as "the model picks the length" (Seedance on Volcengine Ark, wan3.0 on Bailian), reserves the longest duration the model can produce and settles the task on the usage the vendor reports.
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; when no profile declares that upstream name (for example a
channel mapping that sends a declared model to a vendor endpoint ID), the
client-facing model's profile applies. 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;
an unchanged stored expression of a single-plugin model stays saveable after its
profile narrows. A plugin that drops a fact from a profile keeps reporting it at
a value that leaves existing charges unchanged: 0, false, or the enum value
first declared. The pricing editor reads stored expressions the same way and
shows only the remaining fields.
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.
meta.name, meta.description and each usageSchema field description and unitLabel accept LocalizedText. A bare string is equivalent to {en: <string>}; for meta.name it stays a plain name with no name_i18n. 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:
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.
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.descriptionis at most 512 runes per locale. EachusageSchemafielddescriptionis at most 256 runes per locale.- The frontend resolves a locale with exact tag → primary subtag →
en(for examplezh-TW→zh→en). - Description copy in any language must not include vendor currency prices. The same prohibition applies to
usageExampleslabels.
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.descriptionisSong or lyrics generation unit price/生成歌曲或歌词单价, andaction.descriptionisGenerate 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./请求的视频时长,单位为秒。withVideo generation unit price/视频生成单价; replaceWhether audio is generated. Default true./是否生成音频。默认为 true。withWhether audio is generated/是否生成音频. - These are authoring conventions for plugin metadata, not additional runtime validation rules. Existing
LocalizedTextwire types, usage quantities, enum values and billing semantics are unchanged; a numeric field still contains usage, not a price.
Every plugin version published to a marketplace ships a usage guide, README.md, next to its plugin.js. Gateways show it at the top of the install dialog, and the plugin site shows it on the plugin page. Marketplaces check it before they accept a version (see Marketplace checks).
A community plugin keeps both release files beside plugin.js at the commit of its version tag. A plugin kept in a subdirectory of its repository keeps them in that subdirectory.
plugin.js
icon.svg # optional
README.md
README.zh-CN.md # optional translation
CHANGELOG.md
CHANGELOG.zh-CN.md # optional translation
The official repository keeps one guide per plugin key, plugins/<kind>/<key>/README.md. A version directory may hold its own README.md, which then replaces the plugin guide for that version.
Write for the administrator who installs the plugin. Cover these points. Marketplaces do not check them by machine.
- Channel setup: the channel type to choose, the key format the plugin expects, and the default API address with the cases that need a different one.
- The models the plugin supports and the gateway endpoints clients call.
- Billing: what each field in the plugin's price table charges for and how its quantity is measured. Leave out vendor prices.
- One request example sent to the gateway.
- Limits an administrator should know before installing.
- The file is UTF-8 without a byte order mark and at most 256 KiB.
- YAML front matter is optional. When present it starts on the first line with
---, ends at the next line that is exactly---, and is parsed as plain YAML data like the changelog metadata. Its only keys arelocaleandtranslations. README.mdis the English guide and the fallback for every language. When it setslocale, the value isen.translationsmaps a canonical BCP 47 tag other thanento the sibling fileREADME.<tag>.md, for examplezh-CN: README.zh-CN.md. It holds at most 10 entries. OnlyREADME.mddeclares translations. Every listed file must exist and pass the same checks.- A translation file may set
localeto its own tag. It does not declaretranslations. - The body is CommonMark with GitHub tables and strikethrough. Its first block is a level-1 heading, normally the plugin name.
- Allowed content: headings, paragraphs, lists, block quotes, code blocks, tables, thematic breaks, emphasis, strong text, strikethrough, inline code, line breaks and links, including reference links.
- Not allowed: raw HTML of any kind, HTML comments included, images and footnotes. Gateways do not load remote images. Link to a screenshot instead of embedding it.
- Link targets use
http:,https:ormailto:, or are relative. GitHub-style autolinks count as links, so a bare email address becomes amailto:link. A relative link resolves against the page where the file can be read, such as the file on GitHub at the version's commit.
README.md:
---
translations:
zh-CN: README.zh-CN.md
---
# Example Video
Generates short videos with Example's video models.
## Channel setup
Choose the Example channel type and paste the API key from the Example console.
The default API address is `https://api.example.com`. Change it only for a regional endpoint.
## Models and endpoints
| Endpoint | Models |
| --- | --- |
| `POST /v1/video/generations` | `example-video-1`, `example-video-1-fast` |
Clients poll `GET /v1/video/generations/{task_id}` for the result.
## Billing
Each model has a video generation unit price per second.
The gateway charges the duration given in the request.
720p and 1080p videos have separate prices.
## Example
```bash
curl https://gateway.example.com/v1/video/generations \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model": "example-video-1", "prompt": "A cat on a windowsill", "seconds": 5}'
```README.zh-CN.md:
---
locale: zh-CN
---
# Example Video
使用 Example 的视频模型生成短视频。
## 渠道设置
渠道类型选 Example,密钥填 Example 控制台里的 API Key。
默认 API 地址是 `https://api.example.com`,只有使用区域接入点时才需要修改。
## 模型与接口
| 接口 | 模型 |
| --- | --- |
| `POST /v1/video/generations` | `example-video-1`、`example-video-1-fast` |
客户端轮询 `GET /v1/video/generations/{task_id}` 获取结果。
## 计费
每个模型按秒设置视频生成单价,按请求里的时长计费。
720p 和 1080p 视频分别定价。
## 示例
```bash
curl https://gateway.example.com/v1/video/generations \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model": "example-video-1", "prompt": "A cat on a windowsill", "seconds": 5}'
```Every plugin version published to a marketplace ships release notes, CHANGELOG.md, next to its plugin.js. The file describes that version only. Gateways show it in the install dialog's changelog tab, and the plugin site shows it on the plugin page.
---
changelogVersion: 1
plugin: "example"
version: "1.2.0"
locale: "en"
translations:
zh-CN: CHANGELOG.zh-CN.md
---
# Changelog
## [1.2.0]
### Added
- Generate videos with the `example-video-1-fast` model.
### Fixed
- An explicit `seed` of `0` now reaches the upstream request.
### Migration
- Set a price for `example-video-1-fast` before you enable it for users.CHANGELOG.zh-CN.md:
---
changelogVersion: 1
plugin: "example"
version: "1.2.0"
locale: "zh-CN"
---
# Changelog
## [1.2.0]
### Added
- 新增 `example-video-1-fast` 模型的视频生成。
### Fixed
- 请求里明确传入的 `seed` 为 `0` 时,上游现在能收到这个值。
### Migration
- 给用户启用 `example-video-1-fast` 前,先为它设置价格。The file starts with YAML front matter. It begins on the first line with --- and ends at the next line that is exactly ---. Parse it as plain YAML data, without custom tags, anchors or executable constructors. Unknown keys are errors.
| Field | Type | Requirement |
|---|---|---|
changelogVersion |
integer | Required. 1. Reject other values rather than guess their structure. |
plugin |
string | Required. The plugin's meta.key. |
version |
string | Required. The plugin's meta.version. |
locale |
string | Required. A canonical BCP 47 tag, en for CHANGELOG.md. |
translations |
object | Optional, CHANGELOG.md only. Maps a canonical BCP 47 tag other than en to the sibling file CHANGELOG.<tag>.md. At most 10 entries. |
- The body has exactly one
# Changelogheading followed by one release heading,## [<version>], matching the metadata. - Categories are level-3 headings with these exact English names, in this order when present:
Added,Changed,Deprecated,Removed,Fixed,Security,Migration. Omit empty categories. - Each category holds one unordered list. Each entry is a single paragraph of inline Markdown: text, emphasis, strong text, inline code, line breaks and links. Entries hold no images, raw HTML, nested lists, tables or further headings.
- Write links inline, as
[text](url). Reference-style links and strikethrough are not allowed in entries. - Link targets use
http:,https:ormailto:, or are relative. - At least one category other than
Migrationholds an entry. - The file is UTF-8 without a byte order mark and at most 256 KiB.
A translation repeats the plugin, version and categories of CHANGELOG.md in the same order, with its own locale and translated entries. Headings and metadata keys stay in English. Consumers show the translation that matches the reader's language and fall back to CHANGELOG.md when it is missing, unavailable or invalid.
- Write for plugin users and administrators, in natural sentences, one change per entry. Start with what users can do or what behaves differently.
- Name models, paths or fields when they help someone use a feature or upgrade.
- Describe compatibility, default-parameter and billing effects of the changes. Leave out test reports, generic upgrade steps and lists of unchanged behavior.
- Add
Migrationonly when users must act. Put required price changes first, naming the models and the new prices or expressions. - An initial release describes what the plugin can do.
A marketplace that builds its catalog from repositories checks both files at the commit of a new version tag. The community marketplace on the New API site does this, and rejects the version when a check fails:
| Reason | When |
|---|---|
readme_missing |
README.md does not exist. |
readme_invalid |
README.md or one of its listed translations breaks a rule under Usage guide. |
changelog_missing |
CHANGELOG.md does not exist. |
changelog_invalid |
CHANGELOG.md or one of its listed translations breaks a rule under Changelog, or its plugin or version differ from plugin.js. |
The author's plugin page shows the file, the line and the rule that failed, for example CHANGELOG.md line 7: unknown category "Fixes". A download that fails for another reason, such as a network error, is retried later and does not reject the version.
To fix a rejected version, correct the files and publish a new tag, or move the rejected tag to the corrected commit. Moving a tag that was already accepted does not change the published version.
Versions accepted before these checks existed stay installable. The marketplace index lists both files for each version that has them (see the marketplace index format). A version without them shows no usage guide or changelog.
utils exposes synchronous host tools. Existing utilities are unixNow, uuid,
sha256, hmacSHA256, signRS256, jwtSignHS256, base64, base64URL,
base64URLDecode, volcSignV4, json.clone, fetch, and paramOverride,
attachments, media, mediaText and keywords (see Request processing
part); their signatures are declared in v1.d.ts.
The host recognizes a feature from what a plugin exports or returns, such as
an exported parseSubmitEventDelta. A plugin declares a capability only to
switch on behavior that changes what its hooks receive or relaxes a host check:
requiredCapabilities: ["json-order@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, submit-sse-delta@1 and duration-auto@1 remain accepted for
plugins written for earlier hosts; new plugins need none of them.
json-clone@1 and duration-auto@1 have no effect. submit-sse-delta@1 still
requires submitResponseTypes to include sse and the plugin to export
parseSubmitEventDelta.
utils.json.clone(value) returns 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.
json-order@1 keeps JSON member order for upstreams that treat it as
meaningful, such as a model that reads a JSON object as text. The plugin's
decoders receive a JSON request body (ctx.body.value) and its driver hooks
the decoded ctx.requestBody with the members in the order the client sent
them, and the host writes the plugin's JSON request descriptor bodies as
JSON.stringify writes them: members in the order the plugin's objects hold
them, members whose value is undefined left out, no HTML escaping. As in
JavaScript, keys that are array indices come first, in ascending order.
Without the capability, objects reach hooks with their members sorted by key
and the host JSON codec writes bodies sorted the same way. Each decoder and
driver hook call parses the ordered body again, which costs more on large
bodies, so declare the capability only when the upstream needs it. A body
that embeds a file placeholder is still written sorted.
Host functions are synchronous and must bound their own work: the JavaScript
execution interrupt cannot preempt a running Go function. Module scope, top-level
variables included, is shared by all the requests a plugin runtime serves, so
plugins must not keep request data in it. utils.fetch waits on the network
without holding a JavaScript execution slot, and the wait does not count toward
the hook's execution time.
Each hook call may run JavaScript for 5 seconds and keep 512 MiB in
JavaScript. The gateway's PLUGIN_MEMORY_LIMIT_MB setting changes the memory
limit for every plugin; 0 means no limit. The memory limit counts what the
call keeps, its arguments and what the plugin runtime keeps between calls
included. When the call's allocations reach the limit, the runtime measures
the memory still in use, so garbage the call no longer references does not
count, and a call that keeps little may allocate more in total. A call past either limit
stops at once, without running catch or finally, and fails. A builtin that
knows the size of its result before allocating it, such as
String.prototype.repeat or new ArrayBuffer, throws a RangeError instead,
which the plugin can catch.
utils.sha256(data: string | BufferSource, encoding?: "hex" | "base64" | "base64url" | "bytes"): string | ArrayBuffer
utils.hmacSHA256(message: string | BufferSource, key: string | BufferSource, encoding?: "hex" | "base64" | "base64url" | "bytes"): string | ArrayBuffer
utils.signRS256(data: string | BufferSource, privateKeyPem: string, encoding?: "hex" | "base64" | "base64url" | "bytes"): string | ArrayBufferA string argument stands for its UTF-8 bytes; an ArrayBuffer, typed array or
DataView for the bytes it holds. Each argument is at most 64 MiB. encoding
picks the output: lowercase hex, padded base64, unpadded base64url, or
bytes for an ArrayBuffer. It defaults to hex for sha256 and
hmacSHA256, so hmacSHA256(message, secret) returns what it always has, and
to base64url for signRS256, the JWT encoding.
A bytes result can be the key of the next hmacSHA256, which is how AWS
Signature Version 4 and Tencent Cloud TC3 derive their signing keys:
let key = utils.hmacSHA256(date, "AWS4" + secretKey, "bytes");
for (const part of [region, service, "aws4_request"]) key = utils.hmacSHA256(part, key, "bytes");
const signature = utils.hmacSHA256(stringToSign, key);signRS256 signs with RSASSA-PKCS1-v1_5 and SHA-256, the JWT RS256
algorithm, with a PEM PRIVATE KEY (PKCS #8) or RSA PRIVATE KEY (PKCS #1)
block of at most 16 KiB, such as the private_key of a Google service account
key. Its errors never quote the key.
utils.fetch(request: {url: string; method?: "GET" | "HEAD" | "POST"; headers?: Record<string, string>; body?: string; responseType?: "json" | "bytes"}):
{status: number; headers: Record<string, string>; body: JSONValue | string | ArrayBuffer}utils.fetch lets a driver hook read upstream data before it returns, for
example a download link that the vendor hands out only through a separate
lookup. The call is synchronous. The request takes the url, method and
headers members of a request descriptor; the result has the shape of the
response parseTaskResult receives: the HTTP status, the first value of each
response header under its canonical name (Content-Type, Location), and the
body.
bodyis the parsed JSON when the response parses as JSON (JSON.parsesemantics), otherwise the text. AHEADresponse has an empty string.responseType: "bytes"returnsbodyas anArrayBufferof the response bytes instead, for binary data such as the header of a media file read with aRangeheader; aHEADresponse has an empty buffer. The body limit is the same.- The URL may address the same hosts as a request descriptor: the channel Base
URL host and
allowedHosts. The request processing hooks address other hosts (see below). - Requests use the client and channel proxy of poll requests.
- Redirects are not followed. A 3xx response is returned as it is; fetching its
Locationis a new request, checked and counted again. - HTTP error statuses are returned, not thrown.
- Limits: at most 4 fetches per hook call, 15 seconds per fetch, 30 seconds for all fetches of one hook call, and 1 MiB per response body.
utils.fetch throws when:
- the hook may not fetch (see below);
- the request has members other than
url,method,headersandresponseType, or, in the hooks that mayPOST,body; - the method is not
GETorHEAD(defaultGET), or, in the hooks that mayPOST,POST; or theresponseTypeis notjson(default) orbytes; bodyis not a string, exceeds 1 MiB, or is set on a method other thanPOST;- the URL is not an absolute
http(s)URL or exceeds 64 KiB; - there are more than 64 headers, a header value exceeds 8 KiB, or a header is
Host,Content-Length,Accept-Encoding,Connection,Proxy-Connection,Keep-Alive,Proxy-Authorization,TE,Trailer,Transfer-EncodingorUpgrade; - the host is not allowed;
- the fetch times out, or the call runs out of fetches or fetch time;
- the response body exceeds 1 MiB;
- the transport fails.
Hook errors reach API callers, so these messages name the host only, never the path or query.
Fetch is read-only in driver hooks. A request that starts billable vendor work
stays the buildSubmitRequest descriptor, the only request the gateway
accounts for. utils.fetch is available only in driver
hooks that run after channel selection and whose failure is harmless or
retried: buildSubmitRequest (the submission is not sent),
buildQueryRequest, parseTaskResult, buildBatchQueryRequest and
parseBatchResult (one failed poll), and buildContentRequest (one failed
download). The provider hooks login, refresh, revoke, exchangeKey and
scheduled may also POST, for credential exchanges and account maintenance.
They always verify TLS, whatever the channel's TLS setting is.
The request processing hooks ingress (a late ingress too), egress,
reply and report may fetch with GET, HEAD and POST, to reach the
instance's own service, such as a moderation or logging API:
- The URL may address the plugin's
allowedHostsand the host of each non-emptyurlvalue of the instance config (see Instance config), with the same exact host and port matching. The channel Base URL host is not one of them. - Requests use the gateway's default client, without the channel proxy. They always verify TLS and never carry the channel's credentials.
- The limits and errors above apply.
- A dry run runs
ingressandegress, andreplywhen it is given a reply, with the network too, so it sends their requests.
Every other hook, and a dry run of the driver hooks, which has no channel,
throws utils.fetch is not available in <hook>. Hosts without this utility
have no utils.fetch; calling it there throws a TypeError, and the hook
fails like any other hook error. It needs no capability declaration.
The host passes plain JSON values to hooks without copying them: objects,
arrays, strings, finite numbers, booleans and null. The engine converts each
object or array when the hook first reads it and never writes JavaScript
changes back into the host value. Other values (structs, byte slices,
json.Number, raw JSON, invalid UTF-8, non-finite numbers, nil slices, or
nesting deeper than 64 levels) are first normalized through the host JSON codec.
Plugin rules:
- Treat every argument as read-only input. Assigning to an argument changes
only the current call's view; the host, other hooks and later calls never see
it. Return the value the host should use, and use
utils.json.clonewhen a mutable copy is needed. - Do not keep arguments, or objects and arrays read from them, in module variables after the hook returns. Runtimes are pooled across requests, so a kept value can surface in a later request, keeps the original request's data alive, and is still read from the host value it came from.
Host rules:
- Once a value has been passed to a hook, host code must not change it. A
plugin may still hold it, and reading a Go map while another goroutine writes
it is a fatal runtime error that
recovercannot catch and that stops the whole process. Build a new value, or copy it first (jsonValuein the task adaptor copies plain JSON and normalizes everything else). - Hook results may be host values. A hook that returns an argument, or part of one, unchanged hands back the original host value rather than a copy. Copy a result before editing it in place; the request descriptor's file placeholders are replaced on a copy for this reason.
- Integral numbers created in JavaScript come back as
int64and other numbers asfloat64; host values returned unchanged keep their original Go type. Code reading numbers from hook results must accept every numeric type rather than assertingfloat64. The JSON encoding is the same either way.
Every decoder receives one host-parsed body:
{kind: "json", value}
{kind: "form", fields: Record<string, readonly string[]>}
{kind: "multipart", fields: Record<string, readonly string[]>, files: readonly FileReference[]}
{kind: "none"}A JSON value enumerates its object members sorted by key, or in the client's order for a plugin that declares json-order@1.
Native submit and dynamic routes accept JSON and multipart bodies; other media types and an empty body are rejected with 415 before the decoder runs, and query routes take no body. A route that declares models accepts JSON only, because the host reads the model from the JSON body. A decoder rejects the body kinds its vendor format does not use, typically with JSON body required. Gateways released before this extension accept only JSON on native routes.
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. Every uploaded file has its own ref, including several files in one repeated field (image[]): the first file of a field is request_file:<field> and later files are request_file:<field>#<index> with a zero-based index. Plugins copy files[].ref instead of spelling refs by hand. The decoder creates a new canonical {kind:"submit", model, action?, requestBody?, originTaskIds?} intent. Driver hooks consume that normalized requestBody and remain surface-independent.
Channel Request Header Override settings apply after descriptor headers on
submission, query, batch query and credentialed content requests, and replace
descriptor headers of the same name. {api_key} is the key the request
authenticates with. Passthrough rules and {client_header:NAME} apply only to
submission. Content requests keep the host of their URL; credentialless content
requests and utils.fetch are sent as the plugin builds them. An invalid header
value fails the request.
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.
{ __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>, or request_file:<field>#<index> for a later file of a repeated 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.
The host treats every 2xx upstream status (200, 201 Created, 202 Accepted, ...) as a successful submission and calls parseSubmitResponse with {statusCode, headers, body}; any other status fails the submission with the upstream status and body before the hook runs.
buildContentRequest may return contentPath when the upstream returns the artifact inside a JSON reply instead of as the reply, as Vertex AI's fetchPredictOperation returns an inline video:
{url, method: "POST", headers, body: {operationName}, responseType: "json",
contentPath: ["response", "videos", 0, "bytesBase64Encoded"], mimeType: "video/mp4"}The path follows the rules of a JSON change path: string keys and non-negative integer indices, at most 32 segments. The host reads a 200 reply as a long-running operation: an error member answers 502 artifact_upstream_error, a reply that is neither done: true nor an interaction with status: "completed" answers 409 artifact_not_ready, and a value at contentPath that is not a non-empty string answers 410 artifact_gone. Otherwise it decodes the string as base64 (standard or URL-safe) and serves the bytes with mimeType (default video/mp4) as Content-Type; a HEAD client gets the headers only. The reply may not exceed four thirds of MAX_FILE_DOWNLOAD_MB plus 1 MiB (502 artifact_request_rejected), and the client's range and validator headers are not sent with such a request. Other statuses keep the usual mapping. The whole reply is held in memory while the artifact is served. Other hooks ignore contentPath and mimeType.
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). data is null for a task whose submit route declared retainResult: false. Renderers and presenters never receive originTasks.
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.
A native presenter's return value is the JSON body of a 200 reply; the error presenter's is the body of a reply with the host's status. A presenter that must answer with the vendor's own status returns a reply wrapper instead, recognized by its __status member:
{__status: 202, __body: {status: "pending", progress: 40}} // xAI's pending video
{__status: 200, __body: {request_id: "...", output: {task_status: "UNKNOWN"}}} // DashScope's unknown task
{__status: 302, __headers: {Location: url}} // no __body: no body__status is an integer from 200 to 599. __headers may set only Location and Retry-After, as strings. A wrapper with any other member, or an invalid one, fails the presenter (500 for a query, the host fallback for a submission, the sanitized host error for the error presenter). This applies to submit, query and error presenters on native routes; host protocols keep their status codes.
A query presenter receives ctx.artifacts, as Responses renderers do: for one task in SUCCESS whose plugin exports listArtifacts, {key: {key, type, url, mimeType?}}, each url a signed gateway address that downloads the artifact without credentials; {} otherwise, and for a list of tasks. A presenter that throws artifact_not_found is answered like a missing task (404). hailuo answers MiniMax's GET /v1/files/retrieve?file_id= this way: its v1 query reports the task id as file_id, and the retrieve response's download_url is the gateway address of the video.
Hook Error messages are surfaced (truncated and sanitized) to API callers. Plugin authors should write validation failures as user-readable sentences.
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` |
openai_image |
generate | POST /v1/images/generations |
JSON |
openai_image |
edit | POST /v1/images/edits |
JSON or multipart |
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.
protocols.openai_image requires decodeRequest and render and defines no modes. Both operations are synchronous from the client's point of view. The host extracts and pins ctx.model (ctx.operation is generate or edit), the decoder returns the usual submit intent, and buildSubmitRequest runs against the vendor exactly as for native routes. A vendor answering synchronously returns a terminal immediate from parseSubmitResponse; a vendor task API returns a task id, and the host then polls the task inside the client request through the same query hooks and completion settlement the background poller uses (buildQueryRequest, parseTaskResult, extractUsageOnComplete), every few seconds until the task is terminal or the protocol timeout (TASK_PLUGIN_PROTOCOL_TIMEOUT_SECONDS, default 600 seconds) elapses. On timeout the client receives 504 task_timeout; the task remains durable and settles when the poller finishes it. A client that disconnects while waiting leaves the task to the poller as well. A terminal FAILURE is answered with 400 and the task's failure reason. On SUCCESS the host calls render(ctx, task) with the request context and the terminal TaskView; the hook must return an object whose data is an array of OpenAI image entries ({url} or {b64_json}, optionally revised_prompt). The host adds created when absent and owns response_format: for b64_json it downloads every data[].url and adds b64_json (the URL is kept; a failed download logs a warning and leaves the URL). render receives no artifacts; image URLs come from the vendor result. Billing follows the ordinary task contract: reserve on the requested count from extractUsage, settle to the actual count from extractUsageOnSubmit (immediate results, legacy pricing), extractUsageOnComplete (immediate results and polled results, task expressions); the consume log records the billed count as other.image_count when the facts carry image_count.
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.
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.
ctx.upstream is {kind: "vendor"} or {kind: "new_api"} on DriverContext, TaskQueryContext, BatchQueryContext, and the buildContentRequest context, for submission and for background polling alike. new_api means the executing channel is a type-60 New API channel, so the same plugin on the upstream gateway answers. In that case the driver prefixes the URLs it builds with its own native-route prefix (doubao /doubao, alibaba /ali, kling /kling), the form every gateway release serves; jimeng calls Jimeng's Bearer API there, /jimeng/api/common/v3/submit_task_v2 and get_result_v2, which earlier gateways do not serve. The host has already set authHeader to Bearer <channel key> and apiKey to the channel key for every meta.auth type: the channel key of a New API channel is the gateway token, so the vendor auth scheme (oauth2_jwt token exchange, a vendor JWT built from ak|sk) is not run. With vendor nothing changes. The response the driver reads back is what its own native presenter renders on the gateway, where the host has replaced the private upstream id in id / task_id / taskId fields with the gateway's public task id; parseSubmitResponse must accept that shape, and the query URL must resolve on the plugin's own query route with that id. Kling and jimeng keep their sk- key-prefix heuristic for legacy channels pointed at a gateway; the explicit signal takes precedence when it says new_api. ctx.upstream is an additive apiVersion 1 extension; drivers must treat a missing field as vendor.
ctx.requestHeaders on DriverContext, and on the native and protocol decoder contexts, holds the client's request headers, the first value of each under its canonical name (TypeSafe-Danger-Zone-Long-Context arrives as Typesafe-Danger-Zone-Long-Context), so drivers compare names case-insensitively. Content-Type and Accept are always present, empty when the client sent none. The headers that carry gateway credentials (Authorization, Proxy-Authorization, Cookie, X-Api-Key, X-Goog-Api-Key, Mj-Api-Secret) are left out. The host forwards none of them upstream; a driver copies only the headers its vendor defines into its request descriptor. Polling contexts have no client request and carry no headers. Gateways released before this extension pass only Content-Type and Accept, and none to decoders; a decoder that reads a vendor header, as alibaba reads X-DashScope-Sse, needs a gateway that passes them.
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; on a New API channel authHeader is Bearer <channel key> and apiKey is the key for every auth type |
upstream |
{kind: "vendor" | "new_api"} from the executing channel type |
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.
Single and batch task query redirects use the same credential boundary as direct
plugin requests: the channel base URL host and meta.allowedHosts, with the
existing exact host and port matching. An omitted allowedHosts grants no extra
hosts. Plugins that need another credential recipient add it to this existing
list; no redirect option or query descriptor field is needed.
Redirects outside that boundary continue without the original headers except
Accept and Accept-Language; Referer and URL userinfo are removed as well.
After any untrusted hop, credentials stay removed even if a later hop returns
to a trusted host. Arbitrary plugin and channel credential headers are covered.
307/308 may replay a query body only along a trusted chain; other body replays
fail that poll and use the existing poll failure handling.
Every redirect still passes the existing SSRF checks and the limit of 10
requests in a redirect chain. HTTPS-to-HTTP redirects are rejected even to an
allowed host. HTTPS upgrades within the trusted hosts are allowed.
utils.fetch keeps returning redirects to the plugin.
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 TASK_TIMEOUT_MINUTES sweep (72h by default, 4320 minutes) remains the outer deadline.
Marketplace index v1 plugin entries also accept optional sortPriority and website fields, and the booleans task, provider and transform, which name the parts of the latest version; the marketplace lists a plugin under each part it has, and an entry that names none as a task plugin. 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.
Marketplace index v1 version entries may also carry readme and changelog, which point at the version's usage guide and release notes (see Usage guide and Changelog). Each is an object with path, resolved against the index URL like the version path, optional sha256 of the file bytes, optional sourceUrl, an http(s) page where readers can see the file, and optional translations, which maps a canonical BCP 47 tag to an object with the same path, sha256 and sourceUrl fields. The install dialog loads the files only when it opens, from the index's own origin, and picks the translation matching the reader's language with English as the fallback. An entry without the fields shows no usage guide. For release notes it falls back to CHANGELOG.md beside the version's plugin.js.
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.
meta.submitResponseTypes defaults to ["json"]. A plugin that needs SSE
includes "sse" and exports parseSubmitEvent, or parseSubmitEventDelta for
host-managed accumulation (below). 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.
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, and the engine then drops its references to the
finished call's arguments, so an idle instance does not keep the last request's
data alive; values a plugin kept in module variables still keep theirs. Hook
bindings remain live; the host does not freeze
exported functions or object members. Whether an optional hook (the usage
hooks, listArtifacts, buildContentRequest) is exported is decided once,
when the plugin loads: an export that is undefined then counts as absent,
and assigning it later does not make the host call it. No JavaScript execution slot is held while
waiting for the next upstream event.
The host passes the returned state to the next event as is (see
Hook arguments and results); changes a hook makes
to previousState without returning them are not kept. State that is not plain
JSON, such as a typed array, is first normalized through its JSON encoding.
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, except on a native route once an event has reached the
client (see below). Events, accumulated JSON state and persisted task data
are each limited to 1 MiB, events to MAX_FILE_DOWNLOAD_MB on a route that
declares retainResult: false; 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. A request that selects
sse and gets a JSON reply is read as a JSON submission: a gateway released
before relayed streams answers that way on its native routes.
On a native route the host relays the stream: each upstream event reaches the
client as the vendor framed it, comment and keep-alive lines included, before
the plugin's hook reads it, with a 200 text/event-stream reply. A native
client that asks a vendor for a stream (stream: true for Seedream,
X-DashScope-Sse: enable for interleaved Wan output) therefore receives the
vendor's own events; the submit presenter is not called and no JSON follows
them, not even an error. Once one event has reached the client, the submission
settles and is never refunded:
- When the upstream finished the stream (the plugin reported
done, or the upstream closed it), the last state goes toparseSubmitResponseand settles as any immediate result does: the plugin bills what was delivered, and an immediateFAILUREsettles to zero. - When the stream stopped any other way (a read error, the idle timeout or the
submission deadline, a failed hook, invalid state or delta changes, a size
limit), the last valid state still goes to
parseSubmitResponse(nullafter invalid changes), but the task settles at the reserved quota:extractUsageOnCompleteandextractUsageOnSubmitare not called, so the delivered usage cannot lower the charge. An immediateFAILUREstill settles to zero. The client's stream is cut off rather than closed, so a downstream gateway relaying it keeps its reservation too. - When
parseSubmitResponsefails or returns an invalid result, the task succeeds under the gateway's task id at the reserved quota.
A hook error after the first event therefore keeps the reservation. A plugin
reports a vendor's own failure event as a result instead: done, then an
immediate FAILURE from parseSubmitResponse.
The client leaving does not cancel the upstream request of a relayed stream:
the host reads it to the end and settles it. Keep-alive comments reset the idle
timeout, so the upstream request is also bounded by the submission deadline of
the host protocols, TASK_PLUGIN_PROTOCOL_TIMEOUT_SECONDS (600 seconds by
default); reaching it stops the stream like a read error.
Host protocols forward no upstream events: Responses uses its existing presentation flow after the durable submission barrier and retains its independently bounded submission context, and the image protocol answers with JSON. This interface does not add task creation before upstream completion or stream resumption.
A plugin that includes sse and exports parseSubmitEventDelta without
parseSubmitEvent uses parseSubmitEventDelta, and so does a plugin that
declares submit-sse-delta@1. The descriptor still uses responseType: "sse".
A plugin that exports parseSubmitEvent and does not declare
submit-sse-delta@1 keeps the snapshot hook and its original contract, even if
it also exports parseSubmitEventDelta.
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:
{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"}}setreplaces the root at an empty path, replaces an existing array element, or adds/replaces an object member. Parent containers must already exist.appendappends one JSON value to an existing array.appendTextappends 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, or on a
native route that has relayed an event settle it at the reserved quota;
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.
Hooks name request formats with the protocol names of relaykit, the protocol
conversion library of new-api (types.RelayFormat). They are the values of
ctx.format and samples[].format, and they name the upstream protocols of
the relay converters. A host that names protocols differently maps its names to
these before it calls a plugin, so a plugin reads the same names on every host.
| Name | Client paths | Body |
|---|---|---|
openai |
/v1/chat/completions, /v1/completions, /v1/moderations |
Chat Completions, or by path a legacy Completions or a Moderations body |
openai_responses |
/v1/responses |
Responses |
openai_responses_compaction |
/v1/responses/compact |
Responses compaction |
claude |
/v1/messages |
Claude Messages |
gemini |
/v1beta/models/{model}:generateContent and :streamGenerateContent, :embedContent, :batchEmbedContents, the same under /v1/models/, and /v1/engines/{model}/embeddings |
GenerateContent, or by path a Gemini embedding body |
embedding |
/v1/embeddings |
OpenAI Embeddings |
rerank |
/v1/rerank |
Rerank |
openai_image |
/v1/images/generations, /v1/images/edits, /v1/edits |
OpenAI Images |
openai_audio |
/v1/audio/speech, /v1/audio/transcriptions, /v1/audio/translations |
OpenAI Audio |
openai_realtime |
/v1/realtime |
Realtime events, in reply and report; ingress reads a Responses-shaped view named openai_responses (see WebSocket sessions) |
openai_alpha_search |
/v1/alpha/search |
Codex web search |
deepseek_tools |
/v1/tools/search, /v1/tools/fetch |
DeepSeek search and fetch |
task |
Task submissions (see Task and Midjourney submissions) | The task plugin's request |
mj_proxy |
Midjourney submissions | Midjourney |
A name stands for a family of bodies, so a hook that reads a body checks
ctx.path too where the table splits a name by path. In the stage hooks
ctx.format names req.body: the client's body at ingress and request,
and the body built for upstream at outbound, egress and a late ingress,
which is another protocol when the channel converts the request. A channel
that sends a vendor's own format keeps the name of the protocol its body was
built from, so such a body may not match the name. A hook should return
continue for a name it does not handle, so that a name added later passes
through it. Names may be added under apiVersion 1; a name is never renamed or
given another meaning.
new-api has channels, user groups and many users. A gateway without them, such as a single-user desktop gateway, leaves out the members that describe them, and may lack the utilities built on them. A plugin meant for other hosts too checks these members before it uses them and works without them.
| Member | On new-api | On a host without it |
|---|---|---|
ctx.channel of the stage hooks |
{id, type}: the channel of the attempt, type a new-api channel type number |
Absent |
ctx.group |
The user group of the request | Absent |
ctx.user, record.user |
The user who sent the request | Absent |
ctx.token, record.token |
The API token of the request | Absent |
meta.channelTypes, ctx.channelOther |
new-api built-in channel type numbers, and the raw other field of such a channel |
Ignored, and ctx.channelOther absent |
utils.paramOverride |
The new-api channel parameter override language (see utils.paramOverride) | Missing, or throws utils.paramOverride is not available on this host |
The relay route keys and converter names are defined under Relay routes; the converter names are relaykit's converter IDs. How a host picks among the channels or services that serve a route is the host's.
A plugin has a task part when it exports buildSubmitRequest, and a provider
part when meta.relay is not empty. It needs at least one of them, or a request
processing part, and may have several. A type-61 channel bound to a plugin with a provider part serves ordinary
API requests over the meta.relay routes, and its key may hold credentials the
plugin's login stored. Every provider hook is optional and detected from the
plugin's exports: a plugin without hooks is a set of relay routes with static
channel keys, and no JavaScript runs on its requests.
This is an additive apiVersion 1 extension: older hosts reject the unknown
field, and a plugin without buildSubmitRequest fails to load there.
export const meta = {
apiVersion: 1,
key: "vendor",
name: "Vendor",
version: "1.0.0",
author: {name: "Author"},
baseUrl: "https://api.vendor.example",
allowedHosts: ["auth.vendor.example"],
models: ["vendor-large"],
relay: [
{incoming_path: "/v1/responses", upstream_path: "/v1/responses"},
{incoming_path: "/v1/messages", upstream_path: "/v1/generate", converter: "plugin"},
{incoming_path: "/v1/models", upstream_path: "/v1/models"},
],
};A plugin without a task part must not declare routes, protocols,
fetchMode, submitResponseTypes, usageSchema, usageExamples,
usageProfiles, auth, channelTypes or upstreams, and its models is
optional. A plugin without meta.relay that exports prepareRequest,
credentialRejected or a converter hook fails to load; the credential hooks
login, refresh, revoke and signRequest also serve a task part alone (see
Task part with credentials), and so do checkKey, scheduled and the balance hooks (see
Channel config, Scheduled maintenance and Balance). With both parts, each part works as it does alone:
task requests reach the task part, and other requests reach meta.relay.
Each meta.relay entry is one route, with these keys; unknown keys are
rejected. They are the keys and rules of a new-api Advanced Custom channel
route.
incoming_path(required): the client path the route serves, starting with/, without a query.{model}may stand for one path segment, once, as in/v1beta/models/{model}:generateContent; a path with:generateContentalso serves the same path with:streamGenerateContent.upstream_path(required): a path starting with a single/, joined to the channel Base URL, or an absolutehttp(s)URL.{model}in it is replaced with the upstream model. It may start with{config.<name>}to address a URL in the channel config (see Channel config).converter: how the request and the reply are converted,noneby default: one of the table below, orplugin(see after the table).models: the models the route serves, each a name, orre:followed by an RE2 regular expression. An empty list is the route for every other model of its path: one per path, after the path's other routes. A model may not be in two routes of the same path.auth: how the channel key is sent. Without it, asAuthorization: Bearer <key>.{type: "none"}sends no key;{type: "header" | "query", name, value}sets that header or query parameter tovalue, with{api_key}in it standing for the key.pass_through_body_enabled: sends the client body as it came. Only with converternoneorjina_rerank_to_sglang.
/v1/models and /v1/dashboard/billing/credit_grants are management routes:
at most one each, with no models, converter none (/v1/models also takes
plugin, see below), no {model} in upstream_path and no pass-through.
/v1/alpha/search takes converter none only.
| Converter | incoming_path |
Upstream protocol |
|---|---|---|
none |
Any | The client's |
openai_chat_completions_to_anthropic_messages |
/v1/chat/completions |
claude |
openai_chat_completions_to_openai_responses |
/v1/chat/completions |
openai_responses |
openai_chat_completions_to_gemini_generate_content |
/v1/chat/completions |
gemini |
openai_responses_to_openai_chat_completions |
/v1/responses |
openai |
openai_responses_to_gemini_generate_content |
/v1/responses |
gemini |
anthropic_messages_to_openai_chat_completions |
/v1/messages |
openai |
claude_messages_to_openai_responses |
/v1/messages |
openai_responses |
gemini_generate_content_to_openai_chat_completions |
A path with :generateContent or :streamGenerateContent |
openai |
gemini_generate_content_to_openai_responses |
A path with :generateContent or :streamGenerateContent |
openai_responses |
jina_rerank_to_sglang |
/v1/rerank or /rerank |
SGLang rerank |
The names are relaykit's converter IDs, spelled as relaykit registers them:
Claude Messages is anthropic_messages in some and claude_messages in
others. Only these spellings are accepted.
Besides the converters of the table, converter accepts "plugin": the plugin's
convertRequest, convertResponse and convertStreamEvent convert the route,
and the plugin must export all three. They exist only for such a route. Its
incoming_path is any path a route may serve except
/v1/realtime, a WebSocket session the hooks cannot convert, and the
/v1/dashboard/billing/credit_grants management path. It cannot enable
pass_through_body_enabled. Its upstream_path is the default: convertRequest
may send a request to another path (see Converters). Advanced Custom channel
settings do not accept this converter.
"<converter>+plugin" chains a converter of the table other than none and the
hooks: the host converts the client request into that converter's upstream
protocol, convertRequest turns that into the vendor's request, and the
plugin's converted reply goes to that converter's handler, which turns it back
into the client protocol and bills it. For example,
"openai_chat_completions_to_anthropic_messages+plugin" serves chat
completions to a vendor that speaks a variant of Claude Messages, with
convertRequest seeing a Claude Messages body. The route follows the rules of
a "plugin" route and its incoming_path must be one that converter accepts.
On /v1/models the converter takes only the reply: the host sends the request
and convertResponse turns the vendor's model list into an OpenAI one. A plugin
whose only "plugin" route is /v1/models exports convertResponse alone, and
exporting the other two fails to load.
A request on a provider channel takes the same path as on an Advanced Custom channel with these routes: selection, model mapping, billing and stream handling are the host's. The channel competes with ordinary channels under the usual group, priority, weight and affinity rules for the paths and models its routes serve. A plugin update changes the routes of the channels bound to it.
- Fetching models: the
/v1/modelsroute, elsemeta.models. The request is aGETof the route'supstream_path, built like a relay request: the routeauthor the credential'sheadersandquery, the header overrides andsignRequest. It uses the channel's first usable credential, refreshed when it expires within 30 seconds, or its first static key with itsexchangeKeytoken. A 401, or an error with status 401 fromconvertResponse, refreshes the credential or exchanges the key again once and sends the request again. The vendor's reply must be an OpenAI model list; withconverter: "plugin", theconvertResponseresult must be one, ornextfor the next page (see Chained requests). For a channel not saved yet, the request goes to the plugin'sbaseUrlwhen the form has no Base URL, with the form's proxy and header overrides. - Channel test: as on an Advanced Custom channel. When the administrator picks no endpoint and the routes do not serve the test model on the default one, the test uses the first endpoint the routes serve for the model, in this order: chat completions, responses, messages, Gemini, embeddings, rerank, image generation.
- Balance: through the balance hooks (see Balance).
- Responses WebSocket: served when the channel's Responses WebSocket setting is
on and the model's
/v1/responsesroute is native, with converternone. A channel whose plugin has no such route stays excluded.
meta.channelConfig declares the settings an administrator fills in on each
channel bound to the plugin, such as a region or a second upstream address. It
has the format of a request processing plugin's configSchema (see Instance
config) and its rules, with two additions:
- A
{type: "string", format: "url"}field is an upstream address: an absolutehttp(s)URL with an ASCII host and no credentials, query or fragment. The host strips a trailing slash from the value and from the field'sdefault, which the channel form shows as the example value. - An
{type: "object", additionalProperties: <field>}field holds a value per model, keyed by model name, such as a region per model.<field>is astring,number,integerorbooleanfield with atitleand withoutdefaultorvisibleWhen; the plugin falls back to a field of its own for a model without a value. The object field has noproperties.additionalPropertiesis valid inchannelConfigonly.
channelConfig: {type: "object", properties: {
region: {type: "string", title: {en: "Region"}, enum: ["us-east-1", "eu-west-1"], default: "us-east-1"},
uploadUrl: {type: "string", format: "url", title: {en: "Upload address"}, default: "https://upload.vendor.example"},
regions: {type: "object", title: {en: "Region per model"}, additionalProperties: {type: "string", title: {en: "Region"}}},
}},
keyHint: {en: "AccessKey|SecretKey", zh: "AccessKey|SecretKey"},meta.keyHint is LocalizedText of at most 256 runes per locale, shown with the
channel key field as its hint and example. Only a plugin with a task part or
meta.relay may declare channelConfig and keyHint.
A type-61 channel stores the values in its settings as plugin_config, a
JSON object. A save checks them against the bound plugin's channelConfig:
keys the schema does not declare are dropped, a rejected value fails the save
with a message naming the field, and defaults are not stored. Without a
channelConfig the channel stores none.
Every hook that runs for a channel reads its config as ctx.config, with the
defaults of the plugin's current schema applied: the provider hooks, the
balance hooks and the driver hooks of the task part. It is {} without a
schema. After a plugin update, keys the new schema does not declare are left
out, missing keys take its defaults, and a stored value the new schema rejects
reaches the plugin as stored, as a request processing instance's config does,
until the next save checks it.
A meta.relay route's upstream_path may start with {config.<name>}, where
<name> is a format: "url" field of the top level: the request goes to that
address followed by the rest of the path. An empty address stands for the
channel Base URL. A hook's path on such a route replaces the path of that
address and keeps its scheme and host; a hook returns url for any other
address.
A built-in channel type a task plugin serves through meta.channelTypes, such
as Vertex AI for vertex-ai, keeps its own channel form and has no plugin
config: ctx.config is {} there, and the driver hooks read the channel's
raw other field as the string ctx.channelOther. Both are new-api only (see
Host-specific members). On Vertex AI it holds the
region: a region name, or a JSON map of model to region with a default.
export function checkKey(ctx: {baseUrl: string; config: JSONValue}, key: string): void | {error: string | LocalizedText}checkKey runs when an administrator saves a type-61 channel, on each static
key line that was not stored before; plugin credentials are not checked.
ctx.config is the config being saved. It returns nothing for a good key, or
{error} with a message of at most 512 runes per locale for a bad one, and
the save fails with that message in the administrator's language, after the
key's line number on a key of several lines. A throw fails the save with its
message too. It has no utils.fetch: it checks the key's form, such as the
number of parts in AccessKey|SecretKey.
A channel key line is a static key, or a credential the plugin's login or
refresh returned, stored by the host as one JSON line of at most 64 KiB:
{"type":"plugin_credential","plugin":"vendor","data":{},"headers":{},"query":{},"expires_at":0,"account_id":"","label":"","needs_login":false}data is opaque to the host and reaches only the plugin's own hooks as
ctx.credential. headers and query go upstream with every request of the
channel, in place of the route auth. expires_at is unix seconds, 0 for a
credential that does not expire. The host never logs data, headers or
query, and never shows them to an administrator: the channel key view holds
the other members only. Administrators cannot type a credential into a channel
key, and it stays with its channel: a channel copy leaves it behind, and
changing the channel's type or plugin is rejected while the key holds one.
{api_key} in header overrides is empty on a credential line. ctx.apiKey is
the static key line in the hooks that receive it, and empty on a credential.
A multi-key channel holds several credentials and polls them like static keys.
A plugin may not store the same accountId twice across its channels.
export function login(ctx: {
baseUrl: string;
credential?: JSONValue;
login: {state: string; nonce: string; codeVerifier: string; codeChallenge: string; codeChallengeMethod: "S256"; redirectUri: string; session: JSONValue | null};
}, input: null | {callback: Record<string, string>} | {poll: true} | {fields: Record<string, string>}): LoginStepA plugin that exports login can log in on a channel. An administrator starts
it on the channel, which may be created with an empty key for this. The host
calls login(ctx, null) and walks the steps it returns until done; each call
is limited to 60 seconds. ctx.login holds values the host generated for this
login: send state, nonce and codeChallenge to the authorization server,
codeVerifier in the token exchange, and redirectUri, which is the gateway's
server address followed by /oauth/plugin/<key>/callback. ctx.credential is
the data of the credential being replaced when the administrator logs in again
on it.
| Step | Members | Next call |
|---|---|---|
redirect |
url, optional issuer, session |
The browser opens url; the vendor redirects to redirectUri. input.callback holds its query. |
paste |
url, optional hint, issuer, session |
As redirect, for a vendor that only redirects to its own registered address: the administrator pastes the address the browser ended on. |
device |
userCode, verificationUri, optional verificationUriComplete, interval, expiresIn, session |
The browser shows the code and polls. input is {poll: true}. |
pending |
optional interval, session |
The answer to a poll that is not finished; the browser keeps polling. |
fields |
fields, optional session |
The browser shows a form. input.fields holds the values, by field name. |
done |
credential, optional headers, query, expiresAt, accountId, label |
None. The host stores the credential. |
url, verificationUri and verificationUriComplete are absolute http(s)
URLs of at most 8 KiB, without credentials. interval and expiresIn are whole
seconds from 1 to 3600; interval defaults to the previous one, then 5. A
fields entry is {name, label, type?, required?, placeholder?, description?}
with type text (default), password or textarea, at most 32 per step.
label, placeholder, description and hint are LocalizedText of at most
512 runes per locale.
session is JSON the next call reads as ctx.login.session, at most 16 KiB. A
step without session keeps the current one, and null clears it. It stays in
the login flow on the gateway and never reaches the browser, so a plugin may
keep a vendor's device code there. A done step carries none.
The host checks a callback before the plugin sees it. The flow belongs to the
administrator and session that started it and expires after 15 minutes; a
consumed flow, or a callback for another plugin's address, is rejected. state
must match when the callback has one. When the step returned issuer, the
callback's iss must equal it, on error responses too. A callback with error
ends the attempt without calling the plugin, and only the vendor's error code is
shown. A step takes one submit at a time, and polls reach the plugin at most once
per interval, until expiresIn or 15 minutes, whichever comes first. A paste
callback without state relies on PKCE, so always send codeChallenge.
A hook error fails that attempt, and the step can be submitted again. The administrator sees its message; the system log gets the plugin, channel and step only. Do not put tokens or vendor responses into an error message.
A login on a channel writes the credential to the line being replaced, or adds a
line on a multi-key channel, and enables what the host disabled for want of a
login. Adding an account to a single-key channel that holds a credential turns
it into a multi-key channel with both. Logging in on a line with a different
accountId is rejected.
export function refresh(ctx: {baseUrl: string; credential: JSONValue; apiKey: ""}):
{credential: JSONValue; headers?: Record<string, string>; query?: Record<string, string>; expiresAt?: number; accountId?: string; label?: string} | {relogin: true}
export function revoke(ctx: {baseUrl: string; credential: JSONValue; apiKey: ""}): unknownrefresh runs when a credential expires within 30 seconds of a request, when
the upstream rejects one (see below), and in a background scan on the master
node, every minute, for credentials expiring within 10 minutes or three channel
cache sync intervals, whichever is longer. Each call is limited to 60 seconds.
The host runs one call per credential at a time across all nodes, so a rotated
refresh token is sent once. Its result replaces data, headers, query and
expires_at; an omitted accountId or label keeps the stored one, and another
accountId is an error.
Return {relogin: true} when only a new login can restore access. The
credential is marked needs_login and the channel or key is disabled as for an
invalid key, with a notification; the next login on it enables it again. A
thrown error is a temporary failure: the credential is kept, and a request still
uses it until it expires, then fails. API clients never see the thrown message,
and the system log gets the plugin, channel and key index only. Without
refresh the host keeps sending the credential as it is, and the upstream's
answer takes the usual error path.
revoke runs in the background when a credential leaves the gateway: its line is
removed from the channel, the channel is deleted, or a login returned it in done
but the host could not store it. In that last case the host skips revoke when
the credential's accountId is already stored in a credential of the plugin on
any channel, because a vendor whose revoke ends every session of the account
would log that login out. Still revoke only the tokens of the credential
received, never every session of the account. Its result is ignored and a
failure is only logged. Replacing a credential by logging in again does not call
it.
export function exchangeKey(ctx: {baseUrl: string; config: JSONValue; apiKey: string}):
{token: JSONValue; expiresAt?: number} | {error: ConvertError}A plugin whose vendor wants a short-lived token made from the channel key
exports exchangeKey. The host calls it for a static key line before the
line's first request and gives its token, at most 64 KiB of JSON, to the
hooks as ctx.token: signRequest, prepareRequest, credentialRejected,
the converters, the balance hooks, scheduled and the model list fetch. Plugin credentials
keep refresh and have no token, and task requests do not use it. The call is
limited to 60 seconds, and utils.fetch may POST in it, to the channel Base
URL host and allowedHosts, always verifying TLS.
The host keeps the token in memory on each node, per channel, key line, plugin
version, plugin config and Base URL, and exchanges the key again when the
token expires within 30 seconds. expiresAt is unix seconds; a token without
it is kept until the upstream rejects it. Concurrent requests share one call.
A 401 from the upstream, credentialRejected returning true, or a converter
error with status 401, drops the token: the host exchanges the key again and
sends the same request once more.
{error} is a converter error (see Converters): the request fails with its
status and message, and a 401 disables the key when automatic disabling is on
for the channel. A throw or an invalid result fails the request with status
500 and does not disable the key; the system log gets the plugin and channel
only.
export function signRequest(ctx: {baseUrl: string; credential: JSONValue | null; apiKey: string; config: JSONValue; token?: JSONValue}, request: {method: string; url: string; headers: Record<string, string>; bodySha256: string}):
{headers?: Record<string, string>; query?: Record<string, string>}
export function prepareRequest(ctx: {baseUrl: string; path: string; config: JSONValue; token?: JSONValue}, request: {path: string; upstreamPath: string; model: string; stream: boolean; body: JSONValue; headers: Record<string, string>}):
{effects?: readonly RequestEffect[]; path?: string; url?: string} | null | undefined
export function credentialRejected(ctx: {baseUrl: string; path: string; config: JSONValue; token?: JSONValue}, response: {status: number; headers: Record<string, string>; body: string}): booleanctx.config is the channel config (see Channel config) and ctx.token the
exchangeKey token of a static key line (see Key exchange), absent without
one. ctx.path is the client path of the request.
signRequest runs on every upstream request of the channel, after the header
overrides, resends included, and adds the headers and query it returns. url
and headers are the final request; bodySha256 is the hex SHA-256 of the body.
It has no utils.fetch: sign with what the credential holds. The headers it
returns follow the rules of a request descriptor's headers.
prepareRequest runs on JSON request bodies before they are sent, after
convertRequest. It cannot edit the request; it returns effects, and the host
applies all of them or none:
{effects: [
{type: "json.patch", ops: [{op: "add", path: "/store", value: false}, {op: "remove", path: "/metadata"}]},
{type: "header.patch", set: {"X-Vendor-Beta": "2025-01"}, remove: ["X-Client-Hint"]},
]}path or url sends the request to another address than the route's, as a
convertRequest result's does (see Converters), on a native route too: to pick
a host by region, project or model.
json.patch is an RFC 6902 patch, with add, remove, replace, move,
copy and test. header.patch sets and removes request headers. At most 32
effects, 512 patch operations in total and 64 headers per effect. A failed
test, an effect on a protected header or a change to a billing member fails the
request without trying another channel. Protected headers are Authorization,
Proxy-Authorization, Cookie, Host, Content-Length, Content-Type,
Content-Encoding, the hop-by-hop headers, the route's auth header and the
credential's headers; request.headers does not hold them. Billing members are
model, stream, stream_options, n, generationConfig.candidateCount,
size, quality, resolution, seconds, duration and service_tier.
Max-token caps may change: the host bills the usage the upstream reports.
On a credential whose plugin exports refresh, and on a static key line whose
plugin exports exchangeKey, an upstream 401 counts as a rejection, and
credentialRejected decides for other non-2xx responses from their status,
headers and the first 64 KiB of the body. On a route that runs the convert
hooks, so does an error with status 401 from them. The host then
refreshes the credential or exchanges the key again and sends the request once
more; a failed refresh leaves the upstream response to the usual error
handling.
export function convertRequest(ctx: ConvertContext, request: {body: JSONValue; model: string; stream: boolean} | {form: RequestForm; model: string; stream: boolean}):
(UpstreamRequest & {state?: JSONValue}) | {websocket: WebSocketRequest; state?: JSONValue}
export function convertResponse(ctx: ConvertContext & {state: JSONValue | null}, response: {status: number; headers: Record<string, string>; body: string} | {status: number; headers: Record<string, string>; bodyBase64: string}):
({body: JSONValue | string; contentType?: string} | {bodyBase64: string; contentType?: string} | {next: UpstreamRequest & {delayMs?: number}; state?: JSONValue}) & {tools?: ToolCounts} | {error: ConvertError}
export function convertStreamEvent(ctx: ConvertContext & {state: JSONValue | null}, input: {event?: string; data: string} | {dataBase64: string} | {done: true}):
StreamResult | {error: ConvertError}
type ConvertContext = {baseUrl: string; path: string; config: JSONValue; token?: JSONValue}
type UpstreamRequest = ({body: JSONValue | string; contentType?: string} | {form: ConvertedForm} | {file: string; contentType?: string} | {}) &
{path?: string; url?: string; method?: string; headers?: Record<string, string>; stream?: boolean}
type StreamResult = {events?: readonly {event?: string; data: JSONValue | string}[]; chunks?: readonly {bodyBase64: string}[]; body?: JSONValue | string; bodyBase64?: string; contentType?: string;
send?: readonly WebSocketMessage[]; close?: boolean; tools?: ToolCounts; state?: JSONValue}
type WebSocketRequest = {path?: string; url?: string; headers?: Record<string, string>; protocols?: string[]; messages?: readonly WebSocketMessage[]}
type WebSocketMessage = string | {bodyBase64: string}
type ToolCounts = Record<string, number>
type RequestForm = {fields: Record<string, string | string[]>; files: {field: string; filename: string; contentType: string; size: number}[]}
type ConvertedForm = {fields?: Record<string, string | string[]>; files?: {field: string; from: string; filename?: string; contentType?: string}[]}
type ConvertError = {status?: number; message: string; type?: string; code?: string}The hooks turn the client protocol of a converter: "plugin" route into the
vendor's and back. The host then treats the converted response as a native
response of that client protocol and bills it from the usage it holds, so keep
the vendor's usage in what the plugin returns. On a chained route the hooks
convert the chained converter's upstream protocol instead: request.body is
the request that converter built, with the upstream model in its model member
when that protocol carries one, and the reply they return is one of that
protocol. A body string is sent as it is;
any other value is sent as its JSON text. The request sent is limited to
MAX_REQUEST_BODY_MB.
ctx.path is the client path of the route being converted, the same as
request.path in prepareRequest: /v1/chat/completions, /v1/embeddings, or
/v1/models for a model list. A plugin that converts several endpoints reads it
to tell their replies apart. ctx.config and ctx.token are those of the
request hooks, and ctx.state is the state of the request whose response the
hook reads, null when it set none (see Chained requests).
A transcription, translation or image edit sent as multipart form data reaches
convertRequest as form instead of body: its fields, a field with several
values as an array, with the upstream model in model, and its files without
their content. A field's first file is named by the field name and a later one
by field#index, with a zero-based index. The result is exactly one of:
{body}, where each{"$file": "<file>", "encoding": "base64" | "dataUrl"}object in the JSON is replaced with that file's content as a base64 string or as adata:URL of its content type.contentTypedefaults toapplication/json.{form}, a multipart form the host writes with its own boundary:fields, then each offiles, which sends the client filefromasfieldwith that file's name and content type unlessfilenameorcontentTypesets them.{file}, the named client file as the whole request body, with its content type unlesscontentTypesets one.- none of them, for a
GET,HEADorDELETErequest.
$file markers are valid only for a multipart client request.
prepareRequest runs on a {body} result with its markers still in it and not
on a form or file; the host replaces the markers afterwards, signRequest gets
the hash of the final bytes, and a resend after a rejection sends the same
bytes. A result with more than one of body, form, file and websocket,
a marker or file that names no client file, or a request over
MAX_REQUEST_BODY_MB fails the conversion: nothing is sent and nothing is
billed.
path sends the request to another upstream path than the route's
upstream_path, for a vendor with separate streaming and non-streaming
endpoints or with ids in its paths. It starts with a single /, may have a
query, and has no scheme, host, fragment or .. segment. The host joins it to
the channel Base URL as it joins an upstream_path that starts with /, or to
the scheme and host of an upstream_path that is a full URL, and does not
replace {model} in it. The credential's query and signRequest apply to the
final URL, and a resend after a rejection uses the same body and path. An
invalid path fails the conversion, and nothing is sent.
url sends the request to an absolute http(s) address without credentials or
fragment instead, on any host; a result has path or url, not both. The route
auth query, the credential's query and signRequest apply to the final URL,
and the channel's proxy, TLS and HTTP settings to every address.
method is GET, HEAD, POST, PUT, PATCH, DELETE or OPTIONS, the
client's method by default. GET and HEAD send no body, and only GET,
HEAD and DELETE may go without one. headers adds request headers under the
rules of a request descriptor's headers; it cannot set Content-Type, which is
contentType, the route auth header or the credential's headers. stream
decides whether a 2xx response is read as a stream (see Streams).
nativeResponse: true hands a 2xx response to the route's handler as it came,
as a response of the route's upstream protocol, with neither convertResponse
nor convertStreamEvent: for a vendor whose replies already are that protocol,
it saves the hook call per event. A non-2xx response still goes to
convertResponse. A websocket result cannot set it. state is
the ctx.state of the hooks that read the response, at most
STREAM_SCANNER_MAX_BUFFER_MB. websocket opens a WebSocket session instead
of sending a request (see Upstream WebSocket).
convertResponse receives every upstream response, whatever its status, and a
non-2xx response to a stream request too. A JSON, XML, NDJSON, form-encoded or
text/* response, or one without a content type whose body is valid UTF-8,
comes as text in body; any other, such as audio, as base64 in bodyBase64. It
returns the response in the client protocol for a 2xx response, as body or as
base64 in bodyBase64 with contentType, application/json by default, which
is the content type the client gets. It returns {error} for a vendor error,
such as a vendor that answers 200 with an error code in the body. status is an integer
from 400 to 599; without it the error takes the upstream status, or 500 when
that is 2xx. message is required and cut at 4096 characters; type and code
are optional strings, cut at 256. A result with error has no other member. The
client gets the error in its own protocol's format, channel retries and
automatic disabling see its status as an upstream status, and nothing is
billed. When convertResponse throws or returns an invalid result, a 2xx
response fails the conversion; a non-2xx response then takes the usual error
path with its own status and body, as it does when the hook returns a body for
it. {next, state} sends one more request instead of answering, whatever the
status (see Chained requests), and tools counts tool calls (see Tool
counts). On a credential whose plugin exports refresh, an error with status 401
counts as a rejection: the host refreshes the credential once, sends the same
converted request again and converts the new response.
On /v1/models, convertResponse receives the reply to the host's model list
request in the same input and returns {body}, an OpenAI model list such as
{"object": "list", "data": [{"id": "vendor-large", "object": "model"}]}, or
{next, state} for the next page, with the ids read so far in state.
{error} fails the fetch with its message. The status rules above apply: when
the hook throws or returns an invalid result, a 2xx reply fails the fetch with
that failure, and a non-2xx reply fails it with its status. Nothing is billed.
convertStreamEvent receives each event of a 2xx response read as a stream
(see Streams), and once {done: true} at the end of the stream. ctx.state is
the last state it returned, at first the state of the request; a result
without state keeps it. It returns at most 1024
events per call, written to the client as server-sent events, and state and
each call's event data are limited to STREAM_SCANNER_MAX_BUFFER_MB. It may
return {error} as convertResponse does. The host converts events before it
answers the client, until the hook returns its first event or an error or the
stream ends, and then sends the events in order. Meanwhile it sends keep-alive
pings as it does while it waits for the upstream to answer, and a stream that
sends nothing for STREAMING_TIMEOUT fails. An error before the first event is
answered as an error from convertResponse is, 401 rule included. After it,
the error ends the stream as an upstream read error does, and the stream is
billed as any interrupted stream is.
A speech request with stream_format: "sse" is a stream request, converted
event by event. A response not read as a stream is read whole, up to
MAX_REQUEST_BODY_MB, and converted once.
A client request may take several upstream requests: an upload before the
request that uses it, or a job the vendor creates, then polls, then answers.
convertRequest returns the first request with state. convertResponse
reads each response with that state as ctx.state, and returns {next, state}
to send the next request instead of answering:
export function convertRequest(ctx, req) {
return {body: {prompt: req.body.prompt}, path: "/jobs", state: {step: "create"}};
}
export function convertResponse(ctx, res) {
const job = JSON.parse(res.body);
if (job.status === "running") {
return {next: {method: "GET", path: "/jobs/" + job.id, delayMs: 2000}, state: {step: "poll"}};
}
return {body: toClientReply(job)};
}next takes the members of a convertRequest request except websocket and
state, and delayMs, a wait of at most 10000 milliseconds before it is sent.
Its method is POST with a body and GET without one by default. Its $file
markers and form files name the client's files, as in the first request. Each
request gets the route or credential auth, the header overrides, the token and
signRequest, through the channel's proxy; prepareRequest runs on the first
one only. A rejected credential or token is renewed once per client request,
and the rejected request is sent again.
A chain goes on whatever the status of the response the hook read. A non-2xx
response it answers without next takes the usual error path. A 2xx response
read as a stream ends the chain: its conversion answers the client. More than
32 requests after the first fail the client request.
The client request is one attempt, billed from the usage in the reply the
client gets. A chain that fails before the client gets any output is not
billed, so put the usage of earlier responses, carried in state, into the
final reply. The model list fetch (for pages) and the balance hooks chain the
same way, with the same limit.
A 2xx response is read as a stream when its request's stream is true, when
the first request of a streaming client leaves stream out, or when the
response is text/event-stream or application/vnd.amazon.eventstream. A
stream: false request is read whole. convertStreamEvent receives:
- each server-sent event as
{event?, data}, or each non-empty line of a stream without either content type as{data}; - each message of an AWS event stream as
{event, data}:eventis its:event-type,:exception-typeor:error-codeheader,dataits payload as text, or its:error-messageheader when the payload is empty. A bad prelude or message CRC, or a truncated message, fails the stream as an upstream read error does; - each frame of an upstream WebSocket (see Upstream WebSocket);
{done: true}once at the end.
A streaming client gets the events the hook returns. A non-streaming client,
such as a speech request answered with audio, gets chunks or a body instead:
chunks: [{bodyBase64}]adds binary output, written to the client as it comes, with thecontentTypeof the first result with chunks,application/octet-streamby default. Each call's chunks are limited toSTREAM_SCANNER_MAX_BUFFER_MB.bodyorbodyBase64, withcontentType,application/jsonby default, is the whole response and ends the stream: the host closes the upstream. A body cannot follow chunks.
A result with an output for the other kind of client, or a stream that ends without chunks or a body for a non-streaming client, fails the request, and before any output nothing is billed.
convertRequest may return {websocket, state} to talk to the vendor over a
WebSocket session instead of sending a request:
return {websocket: {path: "/v1/realtime?model=" + req.model, headers: {"X-Session": id}, protocols: ["vendor.v1"],
messages: [JSON.stringify({type: "start", text: req.body.input}), {bodyBase64: audio}]}, state: {}};path or url addresses it as a request's does, and the host dials ws for an
http address and wss for https. The handshake is a GET built like a
request of the route: the route or credential auth, headers, the header
overrides, the token and signRequest apply, through the channel's proxy and
TLS setting, within 30 seconds. A handshake rejected with 401 renews the
credential or token once. protocols lists at most 16 subprotocols. The host
sends messages, text or binary from {bodyBase64}, at most 1024, once the
session is open.
Each frame the vendor sends reaches convertStreamEvent as {data} for text
or {dataBase64} for binary, and {done: true} follows when either side
closes the session. Besides its output, a result may carry send, messages in
the shape of messages, and close: true, which closes the session with a
normal close after sending them. A body for a non-streaming client closes the
session after the messages of the same result; for a streaming client the
response ends when the session closes, so return close: true with the last
events unless the vendor closes it. The streaming timeout applies between
frames, and the session closes when the client leaves. send or close
without a WebSocket session fails the request.
convertResponse and convertStreamEvent results may carry tools, the
built-in tool calls the vendor reported, by tool name, such as
{web_search: 2}. The counts of all results of a client request add up and
replace the host's own count of those tools. They are billed at the tool
prices of the model, as the tool calls of built-in channels are; a name
without a price is free. A result has at most 64 names of at most 128
characters, each with an integer count from 0 to 10000.
export function buildBalanceRequest(ctx: BalanceContext):
{method?: string; path?: string; url?: string; headers?: Record<string, string>; body?: JSONValue | string; contentType?: string; state?: JSONValue}
export function parseBalance(ctx: BalanceContext, response: {status: number; headers: Record<string, string>; body: string} | {status: number; headers: Record<string, string>; bodyBase64: string}):
Balance | {next: {method?: string; path?: string; url?: string; headers?: Record<string, string>; body?: JSONValue | string; contentType?: string; delayMs?: number}; state?: JSONValue} | {error: string | {message: string}}
type BalanceContext = {baseUrl: string; credential: JSONValue | null; apiKey: string; config: JSONValue; token?: JSONValue; state: JSONValue | null}
type Balance = {amount?: number | null; currency?: string; unlimited?: boolean;
windows?: {name: LocalizedText; used: number; limit: number; unit?: string; resetAt?: number}[]}A plugin that exports both hooks reports the balance of its type-61 channels;
exporting one of them fails to load. buildBalanceRequest returns the first
request, and parseBalance reads each response with the last state as
ctx.state, returning the balance, or {next, state} for one more request
(see Chained requests), or {error}. The hooks have no utils.fetch.
A request's path joins the channel Base URL, and url is a whole address
whose host must be the Base URL host or in allowedHosts.
The method is POST with a body and GET without one by default, and
contentType defaults to application/json. The credential's headers and
query, the header overrides, the token and signRequest apply, through the
channel's proxy. A 401 refreshes the credential or exchanges the key again
once and sends the request again. A response is read up to 1 MiB.
amount is the balance in currency, a three-letter code such as USD or
CNY, required with an amount. unlimited: true marks an account without a limit. windows
lists at most 32 quota windows: what is used of the limit, finite numbers
in unit (at most 32 characters, such as requests), and resetAt in unix
seconds. A result needs an amount, unlimited or windows. {error} fails the
query with its message, at most 512 runes, from which the host removes the
key and the credential's values.
The channel's balance shows the whole result, its update time and the last
error. The balance column the channel list sorts by holds USD: a CNY amount is
converted with the exchange rate setting, and other currencies, unlimited and
windows-only results leave it as it was. On a multi-key channel each key line
is queried with its own credential or token; the channel shows each key's
result and, when all keys report one currency, their sum.
The scheduled update and "Update all balances", which runs in the background,
disable a channel whose amount is 0 or less when automatic disabling is on for
it, and on a multi-key channel only the keys whose amount is 0 or less. An
error, unlimited or a windows-only result never disables.
Export scheduled to run periodic work for each eligible key of a type-61
channel. The host detects the export without running JavaScript. It needs no
manifest declaration.
export function scheduled(ctx: CredentialHookContext & {
reason: "timer" | "disabled";
keyStatus: "enabled" | "auto_disabled";
disabledReason?: string;
}): {
next?: number;
credential?: JSONValue;
balance?: Balance;
keyStatus?: "enabled" | "auto_disabled";
notify?: LocalizedText;
}ctx has the same channel address, config and prepared credential as the
balance hooks. A static key has credential: null and its key in apiKey,
with token when the plugin exports exchangeKey. A plugin credential has
its data in credential and an empty apiKey. The host refreshes an expiring
credential before the call. disabledReason is present only when the key is
automatically disabled.
The channel and key must be enabled or automatically disabled. A manually disabled channel or key, or a credential that needs a new login, is skipped. Each call handles its current key and can return:
| Member | Meaning |
|---|---|
next |
Finite seconds until the next timer run, clamped to 60 through 86400. Omitted defaults to 3600. |
credential |
Replacement credential data. Omitted keeps it; null stores JSON null. Allowed only on a plugin credential. The complete stored line must fit 64 KiB. The host keeps its account ID, headers, query, expiry and label. Store plugin state, such as remaining vouchers, in this data. |
balance |
A balance with the same validation as parseBalance, written to this key's balance record. |
keyStatus |
enabled restores an automatically disabled key. auto_disabled disables this key when automatic disabling is on for the channel. |
notify |
A LocalizedText message for the channel watchers, at most 1 KiB as normalized JSON across all locales. The host supplies a localized subject with the channel name and ID. |
An automatic disable uses notify in English as its reason, or
Disabled by plugin when it is absent. On a multi-key channel, the host changes
this key's status and disables the channel when all keys are disabled. Status
changes also send the channel's enable or disable notification.
Only the configured master runs scheduled. Configure one master for a
deployment; the scheduler does not elect a leader. It checks due keys in the
existing per-minute credential scan and keeps next-run times in memory. A new
entry gets its first timer run at a random time within 10 minutes, including
after a master restart and when the first scan finds the key already
automatically disabled. Entries for ineligible keys are removed. Timer and
disable-triggered calls share four execution slots, with at most one running
call per key. A pending disable transition stays recorded while the key is
running or all slots are occupied.
When DisableChannel changes a key on the master to automatically disabled,
the master queues a background call with reason: "disabled". The disable
flow does not wait for it. On another node, channel synchronization prompts
the master to scan within a few seconds. The scan detects the transition from
its previously observed enabled status to automatically disabled and queues
that call. The per-minute scan remains a fallback for writes that do not
trigger synchronization. Repeated scans of the same disabled status do not
trigger it again. Disable triggers are limited to once per key per minute.
A disable returned by scheduled does not trigger another call.
The host prepares a channel and key snapshot and calls scheduled with a
60-second limit. The hook and its utils.fetch calls run without channel,
polling or database row locks. ctx.keyStatus describes that snapshot, so
plugins should check it before taking an action meant for a disabled key.
After the hook returns a valid result, the host first takes the channel's
polling mutex, then opens a short database transaction and rereads the key.
MySQL and PostgreSQL use a row lock; SQLite also uses the channel credential
key mutex. A removed key, a manual disable or a credential needing a new
login prevents the result from being applied. A changed key or credential
discards all returned changes and schedules a retry in one minute. The host
checks keyStatus against the freshly read status and writes the credential
data and status together. Cache and ability updates, channel synchronization,
balance storage and notifications follow the commit.
This commit check protects the current key from a stale hook result. Other
channel writers retain their existing behavior, including the possibility
that concurrent writes from different nodes overwrite channel_info.
Vendor actions already performed by the hook cannot be rolled back when the
result is discarded.
utils.fetch accepts GET, HEAD and POST to the channel Base URL host and
allowedHosts, uses the channel proxy, and verifies TLS even when the channel
allows unverified certificates. The fetch count, time and body limits in
Host utilities apply. A throw, timeout or invalid result discards the hook's
returned changes and retries in 15 minutes. The system log records the plugin, channel, key index
and error category, without the error message or credential data.
For example, a plugin can check the account's quota and schedule the next check at its reset time:
export function scheduled(ctx) {
const response = utils.fetch({
url: ctx.baseUrl + "/account/quota",
headers: {Authorization: "Bearer " + ctx.apiKey},
});
if (response.status !== 200) throw new Error("Quota query failed");
const quota = response.body;
const result = {next: quota.resetAt - Date.now() / 1000};
if (ctx.keyStatus === "auto_disabled" && quota.remaining > 0) {
result.keyStatus = "enabled";
result.notify = {en: "Account quota restored", zh: "账号额度已恢复"};
}
return result;
}On a type-61 channel, a task plugin with a login uses the channel's credentials
for its task requests. Each request carries the credential's headers and
query, and meta.auth does not apply: ctx.apiKey is absent, ctx.authHeader
is the credential's Authorization header or empty, and ctx.auth is
{authHeader}. Driver hooks read ctx.credential; the parse hooks of a poll
read the credential that sent it. The host refreshes a credential that expires
within 30 seconds before submitting, polling or fetching content. A task keeps
polling with the account that submitted it, and a batch poll holds the tasks of
one account. A task whose account has left the channel never polls with another
account: its polls fail until it reaches TASK_POLL_MAX_FAILURES and becomes a
FAILURE. A task submitted with a credential without accountId polls with the
plugin's first credential on the channel. There is no resend after a rejection.
signRequest runs on every task request of a type-61 channel, with bodySha256
of the sent body, except on credentialless requests, which carry no credential.
A plugin has a request processing part when it exports at least one stage hook,
ingress, request, outbound or egress, the reply hook (see Reply
check) or the report hook (see Report). The host reads the hooks from the
exports; nothing is declared. A plugin may have this part alone, with no
buildSubmitRequest and no meta.relay. It then follows the rule of a plugin
without a task part: it must not declare the task fields listed under Provider
part, and its models is optional. Such a plugin is never a channel type and
cannot be bound to a type-61 channel. A credential hook (login, refresh,
revoke, signRequest) needs a task part or meta.relay.
optional: true in meta says that a request may go on without the plugin
when one of its instances fails (see Failures). Only a plugin with this part
may declare it.
This is an additive apiVersion 1 extension: older hosts reject configSchema,
optional and samples, and a plugin with only this part fails to load
there.
export const meta = {
apiVersion: 1,
key: "redact",
name: "Request redaction",
description: {en: "Hide personal data in requests", zh: "隐藏请求中的个人信息"},
version: "1.0.0",
author: {name: "Author"},
configSchema: {
type: "object",
properties: {
replacement: {type: "string", title: {en: "Replacement text", zh: "替换文本"}, default: "[redacted]"},
},
},
};
export function ingress(ctx, req) {
return {action: "continue", effects: [], findings: []};
}An administrator adds instances of a plugin in the request plugin settings. An
instance is one configured use of a plugin: {id, name, plugin, enabled, global, groups, models, config}, with one config that the plugin reads as
ctx.config. Whether a failure of the instance stops the request is the
plugin's decision (see Failures), not a setting of the instance. A channel binds instances by id, in order, in its
request_plugins setting.
For each stage the host runs, in this order:
- the instances marked
global, in the order of the settings; - the instances whose
groupsholds the request's group, in the order of the settings; - the instances the channel binds, in channel order.
models limits an instance to some models; an empty list, the default, means
every model. The instance runs only for a request whose model, as the client
asked for it with its @ modifiers removed, is in the list: gpt-4o matches
gpt-4o and gpt-4o@effort:high, not gpt-4o-mini. The match is exact and
holds for every stage, reply and report. Names are trimmed and listed once;
an instance lists at most 100 names of at most 128 characters, and an empty
name is refused. API responses always carry the list, [] when empty. A dry
run of an editor's unsaved instance ignores it.
Each instance runs once per stage, only when it is enabled, its models
match and its plugin exports that stage's hook. An instance whose plugin is missing, switched off
or no longer has a request processing part does not run, and the logs show
nothing of it. Request processing does not follow the plugin master switch,
which stops task and provider plugin channels only. The host takes the list of instances and their
plugin versions when the request starts and keeps them for every stage and
retry of the request; changes apply to new requests. A request that no enabled
instance matches runs no JavaScript and is handled exactly as without plugins.
ingress runs before a channel is selected, so it runs only the instances
known then. An instance the channel binds, or one of a group settled only when
the channel is selected (the auto group), runs its ingress hook late: on
each channel attempt, on the upstream body, before the outbound hooks and the
channel's parameter override. ctx.stage is then "ingress", ctx.format and
req.body are those of the upstream request, and ctx.channel and
ctx.upstreamModel are set. Its effects follow the ingress rules and must
also leave the billing members as they are, as an outbound json.patch must.
A task submission runs it on the client body instead (see Task and Midjourney
submissions).
| Stage | Runs | req.body |
Effects |
|---|---|---|---|
ingress |
Once per request, before the request is parsed and its price is estimated | The client's JSON body, or the text fields of its form body | json.patch, media.text |
request |
Once per channel attempt, after model mapping, before protocol conversion | The client's request | reasoning.set |
outbound |
Once per channel attempt, before the channel's parameter override | The JSON body built for upstream | json.patch, header.patch, media.reencode, media.text |
egress |
Once per channel attempt, after the channel's parameter override | The final JSON body sent upstream | None |
The changes ingress makes are written back to the request body, so parsing,
the price estimate, retries and every later stage use the changed body. An
ingress json.patch must leave model and stream as they are. An
outbound json.patch must leave the billing members listed under Request
hooks of the provider part as they are, as prepareRequest must. egress only
reads: a result with an effect is invalid.
The channel's own settings have the last word. Its parameter override runs on
the body the outbound hooks leave and may replace what they changed, and its
header override, with the headers its parameter override sets, applies after
the headers they set. egress reads the body as it is sent. When a channel
sends a Chat Completions request as a Responses request, outbound and the
parameter override see the Chat Completions body, before the conversion, and
egress sees the Responses body.
On a channel of a provider plugin, egress reads the body the plugin's
convertRequest and prepareRequest start from, in the protocol the gateway
built it in, so the request processing hooks can read it. What the provider
part changes after that, such as its conversion to the vendor's own format,
egress does not see.
On a channel that passes the request body through unchanged, neither
outbound nor a late ingress runs, and egress reads the body as it is
sent. An upstream body the host builds as multipart, such as an image edit,
is handled the same way, except that egress cannot read it. Audio requests
run ingress only.
At ingress, a multipart/form-data client body, such as an image edit, an
audio transcription or a video submission with a reference image, reaches the
hook as the JSON object of its text fields: each field name with its value, or
with an array of values for a repeated field, in the order the fields first
appear. req.form is true. File parts and encoded parts are not passed, and
req.media is empty; utils.attachments lists the file parts by field name,
and utils.mediaText reads their text (see Attachments). A late ingress on a
task submission reads form bodies the same way.
A form body takes only json.patch replace operations that set an existing
text field, or an item of a repeated one, to a string, and media.text, which
leaves a form file as it is. Any other operation or effect, and a value that
holds the form's boundary, is an invalid result: a failure of the instance. When a value changes, the host writes the form again:
the changed fields with their new values, every other part, files included,
byte for byte, in the original order and with the same boundary. A form no
hook changed keeps its bytes. The request body size limit applies to the new
form. The log paths name the fields: prompt, or prompt[1] for the second
value of a repeated field.
Task submissions run the stages too: /v1/tasks/{key}, the host protocols
(/v1/videos, /v1/video/generations and the others), the native submit
routes of task plugins, task plugins that serve the Responses or the Images
API, and Midjourney submissions (/mj/submit/..., /mj/insight-face/swap).
Fetching or polling a task runs no stage; a task's report runs once, at its
final state (see Task reports).
ingressruns once per submission, on the client body, before the body is decoded beyond model routing, validated, priced or reserved. The changed body is what the task plugin decodes, what is priced, what is sent and what the task stores.ctx.formatis"task","mj_proxy"on Midjourney, and"openai_responses"or"openai_image"when a task plugin serves the Responses or the Images API.ctx.pathis the client's path.- A late
ingressruns at the start of each channel attempt, on the client body, before the attempt validates and prices it, so it may change the members that set the price;modelandstreamstay guarded. Each attempt starts from the body the request'singressleft. egressruns on each attempt, on the upstream body the task plugin built, after the attempt reserved its quota and before the body is sent. Itsctx.formatis"task". A body that is not JSON, such as a multipart body the plugin builds, is unchecked.request,outboundandreplydo not run on submissions.- A Midjourney submission has one attempt and runs
ingressonly, its lateingressincluded. The gateway sends the client body asingressleft it, less a few of its own settings (account filter, notify hook, speed flags), soegressdoes not run. Aningressjson.patchmust also leaveaction,customIdandcontentas they are: the model and the price derive from them.
A rejection answers HTTP 400 with error code request_rejected and a failure
HTTP 500 with request_plugin_failed, in the error format of the route, with
the message of a chat request. Midjourney answers {"description": message, "type": "request_rejected" | "request_plugin_failed", "code": 4 | 5}. A
submission stopped at ingress reserves and charges nothing; one stopped at
egress gets its reservation back. Neither is sent to another channel. The
consume log of a submission and the error log of a stopped one carry the
record and the steps (see What the logs show). A submission that stores a
task does not report at submission: the task reports once, at its final
state (see Task reports). A submission that ends before a task is stored, and
every Midjourney submission, reports once, after it is settled and its log is
written.
The built-in plugins request-filter, request-redact and
content-moderation read the text fields of a submission: prompt and
negative_prompt (or negativePrompt) at the top level and under input,
the user messages under input.messages, parameters.negative_prompt, the
text items of content, instances[].prompt, and lyrics and
gpt_description_prompt. content-moderation checks a submission's prompts
as one user message and leaves negative prompts out.
On a Responses WebSocket connection, each response.create is one request,
handled as an HTTP POST /v1/responses is:
ingressreads the create as a Responses body: the event withouttype,event_id,stream,stream_options,background,generateandstream_id, or itsrequestmember withoutgenerateandstream_idwhen it wraps one.ctx.formatis"openai_responses"andctx.path"/v1/responses". It runs before the model access and channel checks.request,outbound,egressand the lateingressrun on each channel attempt, as on HTTP.replychecks the streamed answer (see Reply check), andreportruns once per response, after it settled.- Each create has its own consume or error log, with the record and the steps.
A rejection or a failure answers that create with an error event: status
400 and code request_rejected, or 500 and request_plugin_failed. A reply
stop sends response.cancel upstream and drops the rest of the response's
events. Instead of its terminal event, the client gets an error event with
status 400, the response_id and code response_rejected. The response is
billed from the usage of the upstream's terminal event, as a client cancel is.
The connection stays open for the next create in every case.
A Realtime session (/v1/realtime) runs the plugins on its text only. Its
channel is fixed when it opens, so each hook runs the instances known then and
those the channel binds, in that order, with ctx.channel set and ctx.path
"/v1/realtime".
-
ingressruns on each client event that carries text, before it is sent upstream.req.bodyis a Responses-shaped view of the event:session.updategives{instructions}, fromsession.instructions;conversation.item.creategives{input: [item]};response.creategives{instructions, input}, fromresponse.instructionsandresponse.input.
A member the event lacks is absent, and an event with none of them is not checked. The
audioof an input part is left out of the view and kept.ctx.formatis"openai_responses". Ajson.patchmay changeinstructionsand the items ofinput, which are written back to the event. Any other change to the view's shape, such as adding a member or another item to aconversation.item.create, is a failure of the instance. -
A rejection or a failure drops the event and answers it with an
errorevent:error.coderequest_rejectedorrequest_plugin_failed, anderror.event_idthe event'sevent_id. The session stays open. -
input_audio_buffer.appendand every other client event pass unread. Audio input is not checked: the firstinput_audio_buffer.appendof a session adds one entry peringressinstance withaudio: trueandunchecked: trueto the record (see What the logs show). -
replyreads the text of each response:response.text.deltaandresponse.output_text.delta, as a stream. The audio transcript deltas (response.audio_transcript.deltaandresponse.output_audio_transcript.delta) are outputs too, but read-only: the audio already went out, soholdhas no effect on them and a mask on one stops the response, with codemaskedin the record.ctx.formatis"openai_realtime". The events that repeat the whole text (response.text.done,response.output_text.done,response.audio_transcript.done,response.output_audio_transcript.done,response.content_part.done,response.output_item.done,conversation.item.*andresponse.done) carry the text as the client received it. Audio deltas pass unread, so they may reach the client before text that aholddelays. -
A reply
stopsends the client anerrorevent with coderesponse_rejectedand the upstream aresponse.cancelfor the response. The response's later events are dropped, except itsconversation.item.*events and itsresponse.done, which carry the text the client received. The response is billed from the usage of itsresponse.done, as a client cancel is. The session stays open. -
reportruns once perresponse.done(see Report). -
The session's consume log carries the record and the steps of the whole session: at most 32 record entries, the rest counted in a final
{"truncated": n}entry, and the first 64 steps.
ctx = {
stage: "ingress" | "request" | "outbound" | "egress" | "reply",
format: string, // protocol name of req.body (see Protocol names)
path: string, // client request path
model: string, // model the client asked for
upstreamModel?: string, // request, outbound, egress, and a late ingress
group?: string, // the request's group; new-api only
channel?: {id: number, type: number}, // request, outbound, egress, and a late ingress; new-api only
attempt: number, // channel attempt, 0 first
user?: {id: number, username: string}, // who sent the request; new-api only
token?: {id: number, name: string}, // the API token it came with; absent without one; new-api only
instance: {id: string, name: string},
config: object, // the instance config, with the schema defaults applied
}
req = {
body: object,
headers?: {[name]: string}, // outbound, egress: the headers earlier instances set
media?: [{index, path, mimeType, bytes, width?, height?}],
summary?: {messageCount, toolCount, lastUserTextLength, totalTextLength, hasImages, reasoningEffort}, // request
form?: true, // ingress: body holds the text fields of a form body (see Form bodies)
}ctx.user and ctx.token hold the same values as the report record. In a
dry run, user is the administrator who runs it and there is no token.
new-api always sets group and user; a host without user groups or users
leaves them out, and channel and token too (see Host-specific members).
req.headers holds the headers that earlier instances of the same attempt set
with header.patch. The channel adds its own headers, authentication included,
after the body is built, so hooks do not see them. summary.reasoningEffort
is the effort the client or an @ model modifier already set, "" when none.
Inline media never reaches JavaScript. Each data:<mime>;base64,... string, the
data of a Claude {type: "base64", media_type, data} source and the data of
a Gemini inlineData / inline_data part is replaced in req.body by the
string "[media:N]", and req.media[N] describes it: path is the JSON
Pointer of the media string, bytes the media's decoded size in bytes, and
width and height are given at outbound only. Attachments tells how a
hook reads, sends and rewrites these files and those the request names by id
or URL.
ingress, egress and reply may call utils.fetch (see Host utilities); in
request and outbound it throws. The hook limits (see Host utilities) apply
to each call, and utils.fetch has its own limits.
{action: "continue", effects?: [...], findings?: [{rule, category?, path?, count}], notice?: {en: "Email addresses were hidden", zh: "已隐藏邮箱地址"}}
{action: "reject", code: "blocked.keyword", message?: "Not allowed here", ruleId?: "rule-1", findings?: [...], notice?: "..."}codematches^[a-z0-9_.-]{1,64}$.message,ruleIdand each finding'srule,categoryandpathare at most 256 characters.ruleis required, andcountis a non-negative integer.- At most 64 findings. Findings record what matched, by rule and count, never the matched text.
effects, when present, is an array: at most 32 effects, 512json.patchoperations in total and 64 headers perheader.patch.noticeis an optional plain explanation for the user of what the plugin did: a string or a LocalizedText object withen, at most 8 locales of at most 200 characters each. It appears in the user's log as it is.
A reject may carry findings too, such as one per category that stopped the
request.
reject answers the client with HTTP 400 and error code request_rejected.
The message is the plugin's message, or "Request rejected by policy",
followed by (code: <code>). The request is not sent to another channel.
json.patch and header.patch are the effects of prepareRequest (see Request
hooks of the provider part). A json.patch applies to the real body, where the
media strings are intact; the values of its add, replace and test
operations must not contain a "[media:N]" string. header.patch refuses the
protected and authentication headers.
{type: "reasoning.set", effort, override?} sets the request's reasoning
effort, at request only. effort is one of none (thinking off), minimal,
low, medium, high, xhigh and max. With override: false, the
default, an effort the client or an @ model modifier already set is kept. The
host applies the effort as an @effort model modifier, so each protocol gets
its own field.
{type: "media.reencode", media, format, quality?, maxSide?} re-encodes the
image req.media[media], at outbound only. format is auto, jpeg, png
or webp; auto is JPEG, or PNG for an image with transparency, and webp is
lossless WebP, which keeps every pixel. quality (1 to 100) applies to JPEG and
is refused with webp. When maxSide (16 to 16384) is given, the image is
scaled down so that neither side exceeds it. The host reads JPEG, PNG, GIF (the first frame) and
WebP. The new image replaces the media string only when it is smaller, and its
media type then replaces the old one. Animated GIFs and images over 20 MiB
decoded or 40 megapixels are left as they are.
{type: "media.text", media, text} replaces the text of the text file
"[media:N]" names, at ingress and outbound: its data becomes text,
UTF-8 and at most 1 MiB, and its media type stays. Only a text file of the
JSON body takes new text; utils.attachments and utils.media mark it
rewritable: true. On any other file of the call (a PDF, a DOCX file, an
image, audio, a form file) and on a text file the hook read only in part
(truncated: true), the effect changes nothing: the file is sent as it was,
the instance goes on, and the log shows the file unchecked with reason
not_rewritable. A media that names no file of the call is an invalid
result.
A stage hook reads the files of its request through the host, which keeps their bytes; hooks get references, descriptions and capped text.
utils.attachments() lists them: the request's files in body order, then the
file parts of a form body.
{ref?: "[media:N]", kind, mime, size?, filename?, url?, path, rewritable?: true}refnames inline media and form files;sizeis then the decoded size in bytes. A file the request only names has noref: a chat or Responsesfile_id, a Responsesfile_urlorimage_url, a Claudeurlorfilesource, a GeminifileData/file_datapart, a chatimage_urlwith anhttporhttpsURL.urlis its URL when the body gives one. The host never downloads such a file.kindisimage,document,audio,videoorother;mimemay be""when the request does not say.pathis the JSON Pointer of the file inreq.body, or the field name of a form file.
utils.media(ref) describes one file, {kind, mime, size, filename?, width?, height?, rewritable?}, with width and height for an image the host
decodes, and is null for a string that is not a ref of the call.
utils.mediaText(ref, {maxChars?}) reads a file's text: {text, truncated},
{unchecked: reason} when the host cannot read it, or null for a string
that is not a ref of the call. maxChars, 1 to 131072 and that by default,
bounds the UTF-16 code units returned, and truncated tells that the text was
cut. The host reads text files (UTF-8, or UTF-16 with a byte order mark), PDF
and DOCX files:
| Reason | Meaning |
|---|---|
unsupported |
The type has no text to read, such as an image or audio |
no_text |
The file holds no text, such as a scanned PDF |
too_large |
A PDF or DOCX file over 10 MiB, or one that took over 3 seconds to read |
unreadable |
Damaged, binary or encrypted |
limit |
The request's attachment text, 524288 UTF-16 code units, is used up |
busy |
The server reads two PDF or DOCX files at a time and the wait for a turn passed 5 seconds |
A file is read once per request: every instance that reads it gets the same
text, and the budget counts it once. Reading runs with the hook's clock
stopped and its execution slot lent out, as utils.fetch waits.
utils.fetch sends files. Each JSON string value of its body that equals a
ref of the call is replaced by that file's data URL, data:<mime>;base64,...,
streamed from the request; keys are left alone. One body sends at most 8 refs
and 32 MiB of file data, past which the call throws. A moderation API gets an
image as {type: "image_url", image_url: {url: "[media:0]"}}.
The log records what each instance did with the files (see What the logs
show). A file it read whole, or sent with utils.fetch by its ref or by the
URL of a listed file, is checked; a file of which it read only the first part
is truncated; a file utils.mediaText could not read is unchecked with that
reason.
The plugin decides what happens when something goes wrong. An error its own
code sees, such as its moderation service being down, is handled in the hook:
it returns continue to let the request through or reject to stop it, or
throws.
A hook that throws (an uncaught utils.fetch error included), times out (a
call that waits too long for the plugin runtime included), passes its memory
limit or returns an invalid result, an effect the stage does not allow or a
change to a guarded member is a failure of the instance. The host cannot know whether the request
is safe without it:
- By default a failed instance answers the client with HTTP 500 and error
code
request_plugin_failed, "A request plugin failed". The request is not sent to another channel, and no plugin text reaches the client. - When the plugin declares
optional: true, a failed instance is skipped instead: its effects are dropped, the request stays as it was before it, a warning goes to the system log, and the next instance runs.
These are not failures, and the request goes on:
- A stage that cannot read the body runs none of its instances: a body that
is neither JSON nor, at
ingress, a form body, an upstream body the host builds as multipart, andoutboundand a lateingresson a pass-through channel. The log shows each such instance as unchecked. - An instance whose plugin is missing, switched off, deleted or no longer has a request processing part does not run.
An instance whose plugin is switched on and active on the gateway node's last
plugin sync but did not load there, because it failed to compile or to route,
is unavailable. Every request in its scope fails with HTTP 500 and
request_plugin_failed, as a failed instance of a plugin without optional
does: at ingress for a global or group instance, on the attempt for one the
channel binds. Its step shows action fail and error class unavailable, and
its record entry names the plugin by its key. The system log names the
instance, the plugin and the load error, at most once a minute per instance. A
node learns this from its plugin sync, every 30 seconds: until its first sync,
and for up to one sync interval after a change, it follows what it knew
before.
export function reply(ctx, res) {
const mask = [];
for (const output of res.outputs) {
for (const match of output.text.matchAll(/\b\d{3}-\d{4}\b/g)) {
mask.push({index: output.index, start: match.index, end: match.index + match[0].length, category: "phone"});
}
}
return {action: "continue", mask, hold: res.stream ? 16 : 0};
}reply reads the model's answer before the client gets it, to mask parts of
it or to stop it. It belongs to the request processing part: optional,
configSchema and validateConfig apply to it as to the stages.
The instances are those of the channel attempt that are enabled, match the
model and whose plugin exports reply: the global ones, those of the
request's group and those the channel binds, in that order. A request with
none of them runs no JavaScript, and its response goes to the client exactly
as without plugins.
It runs on Chat Completions, Responses, Claude Messages and Gemini
generateContent and streamGenerateContent, streamed or not, and on the
text of Responses WebSocket and Realtime sessions (see WebSocket sessions). On
every other endpoint it does not run, and the logs show nothing of it. On the
four HTTP endpoints, an
answer the host cannot read passes unchanged and the log shows each instance as
unchecked: a compressed body, a body that is neither JSON nor an event stream,
or Chat Completions content given as parts. An error response passes
unchanged and runs nothing.
It reads only the text of the assistant's answer, in the client's format: Chat
Completions content, Responses output_text parts, Claude text blocks and
Gemini text parts. Reasoning and thinking, tool calls and their arguments,
refusals, images, audio and annotations pass unread and unchanged.
ctx is the stage context with stage: "reply". Its format is the client's
format, and channel, upstreamModel and attempt are those of the attempt.
res = {
stream: boolean,
outputs: [{index: number, text: string, offset: number, final: boolean}],
}- An output is one answer: a Chat Completions choice, a Responses
output_textpart, a Claude text block or a Gemini candidate.indexis the choice, block or candidate index; Responses parts are numbered from 0 in the order they appear. textis what the instance has not released yet: the text it held back at its last call, followed by the new text.offsetcounts the UTF-16 code units of the output the instance released before, sooffset + iis a position in the whole answer.finalis true when the output has ended. Nothing is held after a final call.- A call lists only the outputs with new text and those that just ended.
{action: "continue", mask?: [{index, start, end, category?}], hold?: number, findings?: [...], notice?: "..."}
{action: "pass"}
{action: "stop", code: "policy.blocked", message?: "Not allowed here", findings?: [...], notice?: "..."}- A mask names an output of the call and a span of its
textin UTF-16 code units, as JavaScript indexes strings:0 <= start < end <= text.length. At most 1024 masks. Overlapping masks are merged, and each span is replaced by the fixed text***. A plugin cannot choose the replacement or change the text otherwise. - A mask's
category, at most 256 characters, counts as a finding of that category. hold, 0 to 8192 and 0 by default, keeps the lastholdcode units of each output of the call: the host releasestext[0 : text.length - hold]and gives the rest back at the start of the nexttext. A mask across that point is released whole. A final output is released whole, whatever the hold.passlets the rest of this answer through the instance: the host does not call it again for this answer, and the text it held passes on unchanged. It takes no other member, and the logs show it ascontinue. A plugin with nothing to check, such as one whose settings turn reply checks off, returnspass: that costs one call per output.code,message,findingsandnoticefollow the rules of stage results.
In a stream, the host calls reply on an output's first text, of any length,
then each time the output has gained at least 32 UTF-16 code units since its
last call, when an output ends, and when an event without text, such as a tool
call or the usage, arrives behind unread text. Once every instance of the
answer passed or was skipped, the host calls no more JavaScript for it and
releases the rest of the text as it arrives. If nothing was masked, the rest
of the stream then goes to the client exactly as the upstream answer has it.
Events without text wait behind held text and keep their order; comments and
pings pass at once. The host rewrites the text deltas, and the events that
repeat the whole text get the text as the client received it: Responses
response.output_text.done, response.content_part.done,
response.output_item.done and the final response.completed. Released text
that no event carries goes out in a delta event the host writes in the
client's format. A hold delays that much text; the client gets the rest of the
stream as it comes.
Several instances run as a pipeline: each reads what the previous one
released, its masks applied. A non-stream answer gets one call per instance,
with every output final, and the host corrects its Content-Length.
A stop ends the answer. A non-stream answer becomes HTTP 400 with error code
response_rejected and the plugin's message, or "Response stopped by
policy", followed by (code: <code>). A stream gets an error event in the
client's format, as for other errors in the middle of a stream, then ends, and
the upstream request is canceled. The request is not sent to another channel.
Billing follows the usual rules: a stopped non-stream answer is charged from
the upstream usage, and a stopped stream like one whose client went away right
after the error event.
A failure (see Failures) stops the answer with request_plugin_failed: HTTP
500 for a non-stream answer, an error event in a stream. When the plugin
declares optional: true, the instance is skipped for the rest of the answer
instead, and the text it held passes on unchanged.
Each call has the hook limits. report sees the answer as the client received
it.
Users see what the plugins changed in their request. The host writes the record
from the effects it applied, so a plugin cannot leave a change out. Consume logs
and the error logs of failed requests carry other.request_plugins: one entry
per instance that changed the request, masked the answer, found something or
stopped it, and per instance and stage that could not read the body or the
answer, in run order, for the request's ingress and the attempt that produced
the log. A late ingress shows as ingress. A reply check has one entry per
instance for the whole answer, with stage reply.
{
"plugin": "request-redact",
"name": {"en": "Request redaction", "zh": "请求脱敏"},
"stage": "ingress",
"changes": [
{"op": "replace", "path": "messages[1].content", "type": "string", "length": 132},
{"op": "add", "path": "temperature", "value": 0.5, "overridden": true},
{"op": "remove", "path": "metadata"},
{"op": "move", "from": "a.b", "path": "c"}
],
"headers": {"set": ["x-foo"], "remove": ["x-bar"], "overridden": ["x-foo"]},
"reasoning_effort": "high",
"media": [{"index": 0, "from": {"mime": "image/png", "bytes": 1200000}, "to": {"mime": "image/webp", "bytes": 800000}, "lossless": true}],
"attachments": [
{"kind": "document", "mime": "text/plain", "status": "checked", "rewritten": true},
{"kind": "document", "mime": "application/pdf", "status": "unchecked", "reason": "not_rewritable"}
],
"findings": [{"category": "email", "count": 1}],
"notice": {"en": "Email addresses were hidden"},
"rejected": {"code": "blocked.keyword"}
}nameis the plugin name in every locale it has.changeslists thejson.patchoperations,testleft out, with paths in dotted form and brackets for array items, each at most 256 characters. Anaddorreplaceshows its new value when that is a number, a boolean,nullor a string of at most 64 characters, and otherwise only itstype(string,arrayorobject) andlength(characters, items or members). An entry lists at most 20 changes;more_changescounts the rest.overridden: truemarks a change of anoutboundor lateingresshook that the channel's parameter override then replaced: it changed that member or set again one the plugin removed.headersnames the headers aheader.patchset and removed;headers.overriddennames those the channel's header override then set to another value or removed.reasoning_effortis the effort areasoning.setapplied.medialists the imagesmedia.reencodereplaced, with the media type and size before and after.losslessis true for PNG and WebP and false for JPEG.attachmentstells what the instance did with the request's files at that stage, in request order: every file when it listed them withutils.attachments(), otherwise those it read, sent or rewrote.statusischecked,truncated(only the first part was read) orunchecked, which may carry areason: one ofutils.mediaText,reference(the request only names the file), ornot_rewritable(amedia.texteffect the host could not apply). An unchecked file without a reason was left alone.rewritten: truemarks a filemedia.textrewrote. An entry lists at most 16 files;more_attachmentscounts the rest. File names and contents are never recorded.maskedis the number of spans a reply check masked.findingsgives each finding's category and count.rejected.codeis the code of arejector astop, orrequest_plugin_failedfor a failure that stopped the request or the answer.unchecked: truemarks an instance that did not run at that stage because the stage could not read the body or the answer. Such an entry holds onlyplugin,name,stageandunchecked, andaudio: truewhen it is the audio input of a Realtime session that went unchecked.- The record holds at most 32 entries and 8 KB; the entries past that are
dropped and a final
{"truncated": n}counts them.
The record never contains old values, matched or masked text, header values, rule ids, plugin messages or thrown text.
Administrators also see every step, those that changed nothing included, under
other.admin_info.request_plugins: the instance id and name, plugin, version,
stage, action (continue, reject, stop, fail or unchecked), rule id of
a rejection, number of effects (masks for reply), findings by rule, category
and count, duration and error class. A reply check has one step per instance
for the whole answer. Bodies, header values, matched or masked text and plugin
messages are never logged there either. A dry run answers with the steps, the
error text of each failure and the record users would see. Given a reply
text, a dry run also runs the reply checks on it as on a non-stream answer in
the request's format.
export function report(ctx, record) {
utils.fetch({url: ctx.config.url, method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(record)});
}report receives each finished request, for logging, analytics or
observability. It is not a stage: it cannot change or stop the request, and
its return value is ignored. A plugin may export report alone.
It runs once per client request, after the client has the whole response, the
request has settled and its consume or error log is written, for successful
and failed requests alike. It covers the requests the stages cover and never
runs in a dry run. A Responses WebSocket connection reports once per
response.create, a Realtime session once per response.done, and a task
once, at its final state (see Task reports).
The instances are those of the request's plan that are enabled and whose
plugin exports report: the global ones, those of the request's group and
those the channel of the last attempt binds, each once, in that order. A
request that reached no channel has no bound ones. optional has no effect
here: a report failure never changes the request.
The hooks run off the request path:
- The host copies what the record needs and queues the request. A fixed pool of 8 workers runs the hooks.
- The queue holds at most 256 requests and 64 MiB of their request and response data. A request past either limit is dropped and counted, and a system log warning gives the number dropped, at most once a minute.
- Requests still queued when the gateway stops are lost.
- Each call has the hook limits and the
utils.fetchlimits. A throw, a timeout or a passed memory limit writes a system log warning with the plugin key, the instance id and the error class only, and changes nothing for the request.
So report sees most requests, not every one; billing must not depend on it.
ctx is the stage context with stage: "report". Its format, path,
model, upstreamModel, group, channel and attempt are those of the
last attempt; upstreamModel and channel are absent when the request
reached no channel.
record = {
requestId: string,
user?: {id: number, username: string}, // new-api only (see Host-specific members)
token?: {id: number, name: string}, // new-api only
startedAt: number, // unix milliseconds
durationMs: number, // until the request finished
firstResponseMs?: number, // until the first response bytes; absent when none were sent
request: {
body: object | null, // the client body after ingress; null when it is not JSON
media?: [{index, path, mimeType, bytes}],
},
response?: { // absent for a task (see Task reports)
status: number,
contentType: string,
stream: boolean, // a text/event-stream response, or a WebSocket or Realtime response
body?: object | string, // a non-stream response
events?: [{event?: string, data: object | string}], // a stream response
bytes: number, // what the client received
truncated: boolean, // the response passed 1 MiB
},
usage?: {promptTokens: number, completionTokens: number, cacheTokens?: number, ...},
cost: {quota: number, usd: number},
error?: {status: number, code: string | number | null, type: string, message: string},
requestPlugins: [...],
task?: {...}, // a task's final state (see Task reports)
}request.bodyshows inline media as"[media:N]", asreq.bodyof the stage hooks does, andrequest.mediadescribes it.- The host keeps the first 1 MiB of the response. A JSON body is its value,
other text a string; a binary body is left out. A stream gives its events,
each
dataparsed as JSON when it parses; comments and[DONE]are left out. A truncated stream keeps the whole events before the cut; a truncated non-stream response has nobody. usagehas the token counts the consume log records:promptTokens,completionTokens, and those ofcacheTokens,cacheWriteTokens,cacheCreationTokens,cacheCreationTokens5m,cacheCreationTokens1h,cacheCreationTokens12h,inputTokensTotal,textInput,textOutput,audioInput,audioOutput,audioInputTokenCount,imageOutput,imageCacheTokensandimageCountit has. It is absent when nothing was billed.cost.quotais the quota the request settled, 0 when nothing was charged, andcost.usdthe same in US dollars. They are for reading only.erroris the error the client received.requestPluginsholds the entries ofother.request_plugins(see What the logs show).- A Responses WebSocket or Realtime response has
stream: true,contentType: ""and, inevents, the server events the client received, each as itsdata.statusis 200, or that of theerror. - A Realtime record covers one response, from its
response.createdto itsresponse.done.request.bodyisnull,eventsleave out the audio deltas, andusagehaspromptTokens,completionTokens,cacheTokens,textInput,textOutput,audioInputandaudioOutputfrom theresponse.done.costis what thatresponse.donewas charged, andrequestPluginsholds the entries the session added since the previous report. A stopped response has theerrorthe client received.
The record holds the request and response bodies and the user's name. The
administrator who adds a report instance decides where they go. It never
holds channel keys, request headers, authentication headers or the client IP.
A task reports once, when it reaches its final state: success or failure, a timeout included. It runs after the task is settled and refunded, on the gateway node that finished it, whether a background poll, a client fetch or the timeout sweep did. Fetching or polling a task runs no JavaScript otherwise.
The instances are those that matched the submission (global, the request's
group and those the channel binds, with their models) and exported report,
as the task stored them. At report time each must still be enabled and its
plugin loaded with report; the others are left out. A task with no such
instance at submission stores nothing and costs nothing.
ctx is that of the submission's last attempt: its format, path, model,
upstreamModel, group, channel and attempt. The record has no
response, usage or firstResponseMs:
record = {
... // requestId, user, token and startedAt of the submission
durationMs: number, // from the submission to the final state
request: {body: object | null, media?: [...]}, // as the submission recorded it
cost: {quota: number, usd: number}, // the task's final quota
requestPlugins: [...], // the submission's record
task: {
id: string, // the public task id
status: "SUCCESS" | "FAILURE",
reason?: string, // why it failed
finishedAt: number, // unix milliseconds
result: {
urls: string[], // http and https URLs
texts: [{path: string, text: string}],
truncated: boolean,
},
},
}request.bodyis the submission's JSON body afteringress, with inline media as"[media:N]". It isnullwhen the body was not JSON or, so written, passed 64 KiB.resultreads the task's stored result.urlslists the task's result URL first, then each other http and https URL once, at most 32.textsgives the other strings with their dotted paths, at most 256 and 1 MiB in all. Data URLs and long base64 strings are left out. Past a limit the rest is left out andtruncatedis true. A stored result over 8 MiB is not read.
meta.configSchema describes the instance config, and the request plugin
settings build their form from it. Without it, the config is {}.
Schema = {type: "object", properties: {[name]: Field}, required?: [name]}
Field = {
type: "string" | "number" | "integer" | "boolean" | "array" | "object",
title: LocalizedText,
description?: LocalizedText,
default?: any,
examples?: any[], // the form shows the first one as the example value
enum?: (string | number)[], enumTitles?: LocalizedText[],
minimum?, maximum?, // number, integer
unit?: "byte" | "pixel" | "character" | "second", // form hint: number, integer
minLength?, maxLength?, pattern?, // string; pattern is an RE2 regular expression
format?: "textarea" | "regex" | "url" | "password" | "keywords" | "json", // string: textarea, regex, url, password, keywords; object: json
items?: Field, // array
minItems?, maxItems?, uniqueItems?, // array
properties?: {[name]: Field}, required?: [name], // object
visibleWhen?: {[sibling]: value | value[]}, // form hint: a property only
}
- A member outside this list, or one that does not apply to the field's type,
fails the plugin load. So does nesting deeper than 4 levels: a property is
level 1, and
itemsand nestedpropertiesadd a level each. titleis LocalizedText of at most 256 characters per locale, anddescriptionof at most 512.enumTitleshas one title perenumvalue.enumvalues are strings for a string field and numbers for a number or integer field, without repeats.requirednames declared properties.defaultand everyexamplesvalue must be valid for their field.- An object field without
propertiestakes any JSON object. unitandvisibleWhenare form hints, likeformatother thanurl: they change neither the check nor the defaults nor the config the plugin receives.format: "url"asks for a service address. A non-empty value, thedefaultincluded, must be an absolutehttporhttpsURL without credentials or fragment. The host of each such value is oneutils.fetchmay address in the instance'singress,egress,replyandreporthooks.format: "password"marks a secret, such as an API key: the form hides it. The host checks and saves it like any string.format: "keywords"holds a keyword list, one per line, edited as atextarea. Only a property of the schema itself takes it. The list stays in the host: hooks andvalidateConfigread the property as{count, longest}, the number of distinct non-empty trimmed lines and the UTF-16 length of the longest, andutils.keywords(name, text, {caseSensitive?})returns the distinct keywords of the propertynamethattextcontains, as the list spells them, in the order they end intext, at most 32. It ignores letter case unlesscaseSensitiveistrue. The host builds the matcher for a list once, on its first use, and keeps it while the list does not change.unitnames what a number counts. The form shows abytevalue with a KB or MB choice (1 KB is 1000 bytes; the config keeps bytes), andpixel,characterandsecondas a suffix (px, characters, s).visibleWhenshows a property only while every sibling it names holds one of the listed values:{mode: "lossy", format: ["auto", "jpeg"]}shows it in lossy mode with automatic or JPEG output. Only a property declares it, not the schema or anitemsfield. Each key names another property of the sameproperties, of type string, number, integer or boolean; each value must be valid for that property, as adefaultmust; a list is not empty and has no repeats. A hidden property keeps its value, and the host still checks it and fills in its default. API responses write every value as a list.- The form shows a slider for an integer field whose
minimumandmaximumare both set and at most 100 apart. No member asks for it.
The host checks a config when the instance is saved: types, enum, bounds,
pattern, required, url values, and no property a field with properties
does not declare. It fills in the default of every absent property, also inside array
items and nested objects that are present. Integers are exact up to 2^53. API
responses carry the schema with its properties in the declared order and every
LocalizedText as an object.
A plugin may also export validateConfig(config) to check what the schema
cannot express, such as whether a regular expression compiles in JavaScript.
The host calls it when an instance is saved, after the schema check, with the
defaults applied; without configSchema it receives {}. A throw refuses the
config, and its message, cut to 256 characters, is the error the administrator
sees. The return value is ignored, and utils.fetch and utils.paramOverride
throw inside it. validateConfig needs a stage hook, and an export of that
name that is not a function fails the plugin load.
Titles and descriptions follow the description writing and translation
conventions: short phrases, equivalent in every language, without trailing
punctuation. A title names the setting (Replacement text / 替换文本); a
boolean title names the state true turns on (Case sensitive / 区分大小写).
The unit belongs in unit and the condition in visibleWhen, not in the
title or description: Maximum side length / 最长边, not
Maximum side length (pixels) or Lossy mode only: ....
samples: [
{
name: {en: "Email and phone number", zh: "邮箱和电话号码"},
lang: "en",
format: "openai",
body: {model: "gpt-4o-mini", messages: [{role: "user", content: "Write to jane.doe@example.com"}]},
reply: "Sure, I wrote to jane.doe@example.com",
},
],meta.samples lists requests the request plugin settings offer to try the
plugin with. Only a plugin with a request processing part may declare it.
nameis LocalizedText of at most 64 characters per locale.langis the base language of the sample's text, two or three lowercase letters such asenorzh. Without it, the sample suits any language.formatisopenai,openai_responses,claudeorgemini(see Protocol names), andbodya request body in that format, at most 16 KB as JSON.replyis an optional answer text of at most 4 KB, for trying areplyhook.- At most 8 samples, and at most 3 with the same
lang; the samples withoutlangcount as one value. - A sample carries no instance config.
Samples carry text only. The plugin fails to load, with an error that names the
sample and the JSON path, when a string of body or reply starts with
data:, is 100 characters or more of only A-Z a-z 0-9 + / = _ -, is a URL as
a whole, or holds a "[media:N]" placeholder, and when an object of body has
an inlineData, inline_data, fileData or file_data member, a data
member next to media_type, mimeType or mime_type, or type: "base64".
Leading and trailing spaces do not count.
The string "[sample-image]", as a whole element of a content or parts array,
stands for one image part: the settings put an image of their own there.
Anywhere else it fails the load.
GET /api/task_plugin_options gives each plugin its samples as written,
with every name as a locale object, and [] when it has none.
utils.paramOverride(body: object, override: object):
{body: object, headers?: {set?: {[name]: string}, remove?: [name]}} | {reject: {code: string, message: string}}utils.paramOverride is new-api only (see Host-specific members). It runs the
new-api channel parameter override language on body, with the conditions
reading ctx: model, upstream model, group, channel and format. override has
the shape of a channel's parameter override setting; the language is described
in the new-api channel management guide
(https://docs.newapi.ai/en/docs/guide/console/channel-management#parameter-override-settings-documentation).
A return_error operation comes back as reject. The call changes nothing by
itself: the hook returns the effects that make the change. It is available in
outbound only; in any other hook it throws utils.paramOverride is not available in <hook>, and on a host without it, utils.paramOverride is not available on this host. A plugin meant for other hosts too calls it inside
try and works without it.
Realtime sessions run ingress, reply and report on their text only (see
WebSocket sessions): their audio, the client events other than the three
listed there and request, outbound and egress are not covered. Binary
and URL-encoded bodies do not reach stage hooks. A multipart body the host
builds for upstream is unchecked at egress, and on chat routes outbound
and a late ingress do not read form bodies. reply covers the four HTTP
endpoints listed under Reply check and the WebSocket sessions only: not task
requests, Midjourney, embeddings, images, audio, rerank or the other
endpoints. A request whose body is not JSON, a form body included, reaches
report with a null body.