feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+27 -4
View File
@@ -143,9 +143,17 @@ Redis 限流使用原子 Lua 固定窗口,替代旧的近似滑动窗口 List
## PAT 调用契约
`User.AccessToken`(面板 PAT)继续支持 `Authorization: Bearer <pat>`,也兼容原有的单值 `Authorization: <pat>`。`New-Api-User` 不再参与鉴权,外部脚本不需要再发送 Bearer 与用户 ID 双请求头。这是有意的调用契约简化;旧 PAT 本身无需重新生成。
面板 PAT(个人访问令牌)通过 `Authorization: Bearer <pat>` 发送,也兼容单值 `Authorization: <pat>`;不接受 URL 参数。`New-Api-User` 不参与鉴权。
PAT 不是浏览器登录会话,不能调用登录会话管理接口,也不能签发绑定具体登录会话的 Security Proof。
- 令牌格式为 `nap_` 加 43 位 base62 随机串(约 256 bit)。明文只在创建时返回一次,数据库只保存 SHA-256 摘要,该摘要同时作为审计记录中的 `token_ref`。
- 每个用户最多 20 个令牌,存放在 `user_access_tokens` 表。创建时必须选择 scope 和过期时间:默认 30 天,最短 1 小时,也允许永不过期(界面会提示风险)。过期令牌立即失效,超过保留期后由 master 节点清理。
- scope 形如 `resource:action`。个人权限(资料、API 令牌、用量、钱包、账户安全)和部分后台权限由令牌目录定义;受 Casbin 保护的后台权限直接复用管理员权限矩阵的 resource 与 action。只能授予自己当前拥有的权限,并且每个请求都会重新读取角色和权限:撤回某项管理员权限后,携带该 scope 的令牌立即失去对应能力。
- 每条面板路由都必须声明所需 scope。未声明的路由一律拒绝(403 `ACCESS_TOKEN_ROUTE_UNDECLARED`),scope 不足返回 403 `ACCESS_TOKEN_SCOPE_DENIED`,令牌过期返回 401 `ACCESS_TOKEN_EXPIRED`。
- PAT 不能创建、改名或撤销 PAT,也不能管理登录会话;这些接口只接受浏览器登录会话。
- PAT 可以完成二次验证,Proof 绑定到该令牌,并且令牌必须持有该操作对应的 scope:`channel.key.read` 需要 `channel:secret_view`,`admin.user.*` 需要 `user:write`,本人的密码、2FA、Passkey、账号绑定和注销需要 `account_security:write`。PAT 不能使用 OAuth 验证方式。
- 通过 PAT 完成的安全变更(如修改密码、2FA、Passkey)会让该用户的所有浏览器登录会话下线,令牌本身保持可用。
- 用户被禁用期间,其令牌全部不可用,重新启用后恢复;注销或删除用户时,其令牌被删除。
- 最后使用时间和 IP 每个令牌每分钟最多更新一次;更新失败不影响请求。
## 临时鉴权流程与二次验证
@@ -153,13 +161,25 @@ OAuth state、2FA pending、Passkey ceremony、Telegram bind 等临时状态存
标准 OAuth 绑定回调由 popup 通过同源 `postMessage` 交给 opener;只有 opener 使用自身内存中的 Bearer 调用后端绑定接口。Telegram 绑定先由已登录前端创建绑定 AuthFlow,再让 widget 回调携带路径中的 `flow_token`,回调时会重新确认原登录会话仍有效。Telegram 的已签名 widget assertion 也会登记为一次性凭据,重复回放会被拒绝。
敏感操作使用有效期 5 分钟的 `X-Security-Proof`:
敏感操作使用有效期 1 分钟的 `X-Security-Proof`:
- `channel.key.read`:查看渠道密钥;
- `passkey.register`:注册 Passkey;
- `passkey.delete`:删除 Passkey。
Proof 同时绑定用户、登录会话、用户鉴权版本、会话版本和 scope,不能跨用户、跨会话或跨用途复用。
管理员对其他用户执行的高风险操作同样要求 Proof,且 scope 的 context 绑定被操作用户,签发给某个用户的 Proof 不能改用于另一个用户或另一种动作:
- `admin.user.delete`(`{"user_id"}`):`DELETE /api/user/:id` 与 `POST /api/user/manage` 的 `delete`;
- `admin.user.manage`(`{"user_id","action"}`,action 为 `disable` / `enable` / `promote` / `demote`):`POST /api/user/manage`;额度调整 `add_quota` 不要求 Proof;
- `admin.user.update`(`{"user_id"}`):`PUT /api/user/` 在请求包含新密码或 `admin_permissions` 时要求;仅修改显示名、分组、备注不要求;
- `admin.user.create`(`{"role"}`):`POST /api/user/` 创建管理员角色时要求;创建普通用户不要求;
- `admin.user.passkey.reset`(`{"user_id"}`):`DELETE /api/user/:id/reset_passkey`;
- `admin.user.2fa.disable`(`{"user_id"}`):`DELETE /api/user/:id/2fa`;
- `admin.user.binding.clear`(`{"user_id","binding_type"}` 或 `{"user_id","provider_id"}`):`DELETE /api/user/:id/bindings/:binding_type` 与 `DELETE /api/user/:id/oauth/bindings/:provider_id`。
这些 `admin.user.*` scope 只对管理员及以上角色签发;已启用 2FA 或 Passkey 的管理员必须使用其中之一,未启用时回退到密码(或已绑定的 OAuth)重新认证;密码登录被关闭时不接受密码验证。通过 PAT 调用上述接口时,令牌需持有 `user:write` 并完成二次验证;旧版令牌不能调用。
Proof 同时绑定用户、登录会话、用户鉴权版本、会话版本和 scope,不能跨用户、跨会话或跨用途复用。对 PAT 而言,Proof 绑定的“登录会话”就是令牌本身。
启用了 2FA 的用户注册 Passkey 时,register begin 与 finish 都必须携带有效的 `passkey.register` Proof;finish 会在消费一次性 AuthFlow 之前重新验证 Proof。未启用 2FA 的首次 Passkey 注册不要求该请求头。
@@ -174,3 +194,6 @@ Proof 同时绑定用户、登录会话、用户鉴权版本、会话版本和 s
- Redis 限流从近似滑动窗口改为原子固定窗口,存在明确的边界双倍突发语义。
- 用户级模型成功请求限流的 UTC 时间戳在滚动升级期间存在一个窗口的混合格式过渡,期间可能临时误放行或误拒绝。
- 自建客户端应按新的 AuthBundle、`flow_token` 和 Security Proof 契约升级;PAT 客户端可直接移除 `New-Api-User`。
- 旧版 PAT 保留在 `users.access_token` 中,自新版首次启动起 30 天内照常可用,之后自动停用并返回 401 `ACCESS_TOKEN_LEGACY_RETIRED`。停用日期记录在 `options` 表的 `LegacyAccessTokenRetireAt`,由服务端写入,不能通过设置接口修改。过渡期内不能再生成旧版令牌,但可以撤销。
- 删除了 `/api/user/token*` 接口,改为 `/api/user/access_tokens`。
- 降级到旧版本后,新令牌不可用;未撤销的旧版令牌会重新可用,因为旧版本不认停用日期。
+9
View File
@@ -0,0 +1,9 @@
# 分组检测事件
`GET /api/group-status/events` 返回 `text/event-stream`。连接建立时返回 `event: status` 和最近六小时检测历史;视觉检测、用户指纹检测结算,以及自动指纹结果保存或管理员重置后发送新快照。数据沿用主站分组名称和公开检测字段。
每二十秒发送注释心跳,不查询状态。失败时客户端重连并按历史记录 ID 去重。慢客户端的唤醒会合并,但快照仍包含窗口内全部记录。当前实现用于单实例部署,最多同时连接 32 个订阅者。多实例部署需先将事件广播接入共享总线。
AstrBot 1.3.0 接收事件,保留持久待发队列和 OneBot 回执检查。首次切换到指纹与图片联合规则只建立基线。任一有效结果降智则通报降智,两项最新有效结果均正常才通报恢复;运行失败不覆盖有效结果。超过六小时的断线历史不能保证完整补齐。
视觉检测成功执行后,命中“音响”或“音箱”为 normal,否则为 degraded。超时、请求失败、空文本或响应不可解析为 failed/unknown。custom.78 的历史请求失败在读取时按 unknown 解释,历史非空回答未命中仍作为有效 degraded 结果;不改写旧记录。
+7 -3
View File
@@ -126,9 +126,13 @@ the plugin's declared native routes. The generic management surface remains
## Security boundary
Plugins have no `fetch`, filesystem, `require`, imports, async functions, or
environment access. The host limits execution time, concurrency, input size,
allowed request hosts, and resolves OAuth credentials outside JavaScript.
Plugins have no global `fetch`, filesystem, `require`, imports, async
functions, code compiled from strings (`eval` and the `Function` constructors
throw an `EvalError`), or environment access. A few driver hooks may read
upstream data with [`utils.fetch`](./v1.md#reading-upstream-data-with-utilsfetch):
`GET` or `HEAD` only, to the hosts a request descriptor may address. The host
limits execution time, concurrency, input size, allowed request hosts, and
resolves OAuth credentials outside JavaScript.
Multipart files enter JavaScript only as opaque references.
This is not a hard memory-isolation boundary. A plugin sees data needed for the
+25 -9
View File
@@ -1,5 +1,10 @@
export type JSONValue = null | boolean | number | string | readonly JSONValue[] | {readonly [key: string]: JSONValue};
export type HostCapability = "json-clone@1" | "submit-sse-delta@1";
/** json-clone@1 and submit-sse-delta@1 are accepted for earlier hosts; new plugins need neither. */
export type HostCapability = "json-clone@1" | "submit-sse-delta@1" | "json-order@1" | "duration-auto@1";
/** Kinds of upstream a driver can address: the vendor API itself, or another New API gateway with the same plugin installed. */
export type UpstreamKind = "vendor" | "new_api";
/** Host-injected on every driver hook context. With "new_api" the driver uses its own native-route prefix and the host already set Bearer credentials. */
export interface UpstreamContext {kind: UpstreamKind}
export type MutableJSON<T> = T extends readonly (infer Item)[] ? MutableJSON<Item>[] : T extends object ? {-readonly [Key in keyof T]: MutableJSON<T[Key]>} : T;
export interface HostUtils {
hasCapability(name: string): boolean;
@@ -13,7 +18,12 @@ export interface HostUtils {
base64URL(value: string): string;
base64URLDecode(value: string): string;
volcSignV4(request: {method: string; url: string; headers?: Record<string, string>; body?: string; accessKey: string; secretKey: string; region?: string; service?: string; timestamp?: number}): Record<string, string>;
/** Synchronous read-only request from driver hooks to the channel Base URL host or allowedHosts. At most 4 per hook call, 15 s each, 30 s in total, 1 MiB per body; redirects are returned, not followed. */
fetch(request: FetchRequest): FetchResponse;
}
export interface FetchRequest {url: string; method?: "GET" | "HEAD"; headers?: Record<string, string>; responseType?: "json" | "bytes"}
/** headers holds the first value of each header under its canonical name; body is parsed JSON when the response parses, otherwise text, and "" for HEAD; with responseType "bytes" it is an ArrayBuffer of the response bytes. */
export interface FetchResponse {status: number; headers: Record<string, string>; body: JSONValue | string | ArrayBuffer}
declare global {const utils: HostUtils;}
export type FileReference = Readonly<{ref: string; field: string; filename: string; mimeType: string; size: number}>;
export type FilePlaceholder = Readonly<{__fileRef: string; encoding: "base64" | "dataUrl"; mimeType?: string; maxBytes?: number}>;
@@ -24,17 +34,21 @@ export type DecodedBody =
| Readonly<{kind: "none"}>;
export interface NativeDecodeContext {method: string; path: string; params: Readonly<Record<string, string>>; query: Readonly<Record<string, readonly string[]>>; body: DecodedBody}
export interface ProtocolDecodeContext extends NativeDecodeContext {protocol: "openai_responses" | "openai_video"; operation: string; model: string; stream: boolean}
export interface ProtocolDecodeContext extends NativeDecodeContext {protocol: ProtocolName; operation: string; model: string; upstreamModel?: string; stream: boolean}
export type SubmitIntent = {kind: "submit"; model: string; action?: string; requestBody?: unknown; originTaskIds?: readonly string[]};
export type QueryIntent = {kind: "query"; taskIds: readonly string[]};
export type TaskIntent = SubmitIntent | QueryIntent;
export interface NativeRoute {method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; path: string; type: "submit" | "query" | "dynamic"; action?: string; taskIdParam?: string; decode?: string; render: string; models?: readonly string[]}
export type ProtocolName = "openai_responses" | "openai_video";
export interface NativeRoute {method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; path: string; type: "submit" | "query" | "dynamic"; action?: string; taskIdParam?: string; decode?: string; render: string; models?: readonly string[]; retainResult?: boolean}
export type ProtocolName = "openai_responses" | "openai_video" | "openai_image";
export type ResponsesMode = "stream" | "sync" | "background";
export type ProtocolClaim =
| "openai_video"
| "openai_image"
| {name: "openai_responses"; supports: readonly ResponsesMode[]; models?: readonly string[]}
| {name: "openai_video"; models?: readonly string[]};
| {name: "openai_video"; models?: readonly string[]}
| {name: "openai_image"; models?: readonly string[]};
/** One entry of the OpenAI ImageResponse `data` array rendered by protocols.openai_image.render. */
export type ImageResponseEntry = {url?: string; b64_json?: string; revised_prompt?: string};
export type LocalizedText = string | ({ en: string } & Record<string, string>);
export type UsageFieldSchema =
| {type: "number"; unit: "count"; unitLabel?: LocalizedText; description?: LocalizedText}
@@ -43,11 +57,11 @@ export type UsageFieldSchema =
| {enum: readonly string[]; unitLabel?: never; description?: LocalizedText; enumLabels?: Readonly<Record<string, LocalizedText>>};
export type UsageExample = {label: string; facts: Readonly<Record<string, string | number | boolean>>};
export type UsageProfile = {models: readonly string[]; schema: Readonly<Record<string, UsageFieldSchema>>; examples?: readonly UsageExample[]};
export interface Meta {requiredCapabilities?: readonly HostCapability[]; submitResponseTypes?: readonly ("json" | "sse")[]; sortPriority?: number; website?: string; apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; baseUrl?: string; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly<Record<string, UsageFieldSchema>>; usageExamples?: readonly UsageExample[]; usageProfiles?: readonly UsageProfile[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
export interface Meta {requiredCapabilities?: readonly HostCapability[]; submitResponseTypes?: readonly ("json" | "sse")[]; sortPriority?: number; website?: string; apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; baseUrl?: string; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; upstreams?: readonly UpstreamKind[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly<Record<string, UsageFieldSchema>>; usageExamples?: readonly UsageExample[]; usageProfiles?: readonly UsageProfile[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record<string, unknown>}
export interface DriverContext {requestBody: unknown; requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; data: unknown; state: unknown}
export interface BatchQueryContext {baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; tasks: readonly TaskQueryContext[]}
export interface DriverContext {requestBody: unknown; requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; upstream: UpstreamContext; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; upstream: UpstreamContext; data: unknown; state: unknown}
export interface BatchQueryContext {baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; upstream: UpstreamContext; tasks: readonly TaskQueryContext[]}
export type HookHTTPResponse = {readonly status: number; readonly headers: Readonly<Record<string, string>>}
export interface RequestDescriptor {responseType?: "json" | "sse"; url: string; method?: string; headers?: Record<string, string>; /** JSON body may contain FilePlaceholder objects at any depth; the host replaces each with a Base64 or data-URL string. */ body?: unknown; credentialless?: boolean; action?: string; model?: string; rewriteModel?: string; bodyType?: "json" | "multipart"; parts?: readonly {name: string; value?: unknown; fileRef?: string; filename?: string}[]}
export interface UpstreamResponse {statusCode: number; headers: Readonly<Record<string, readonly string[]>>; body: unknown}
@@ -58,6 +72,8 @@ export declare const native: Record<string, ((ctx: NativeDecodeContext) => TaskI
export declare const protocols: {
openai_responses?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; renderEvents?(ctx: unknown, task: TaskView, previousState: unknown): unknown; renderFinal?(ctx: unknown, task: TaskView): unknown};
openai_video?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; render(ctx: unknown, task: TaskView): unknown};
/** render returns the OpenAI ImageResponse; the host adds `created` when absent and resolves response_format b64_json. */
openai_image?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; render(ctx: unknown, task: TaskView): {created?: number; data: readonly ImageResponseEntry[]} & Record<string, unknown>};
};
export declare function buildSubmitRequest(ctx: DriverContext): RequestDescriptor;
export interface SubmitEvent {event: string; id: string; data: string}
+196 -25
View File
@@ -24,11 +24,13 @@ export const meta = {
`submit` and `dynamic` routes require `decode` and `render`. `query` routes require `render`, prohibit `decode`, and use `taskIdParam` (default `task_id`). Names refer only to callable members of `native`. Route conflicts use method plus normalized path shape and the route index is published atomically with its plugin generation.
`routes[].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. 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; a missing, non-string, or unlisted model is rejected with 400 (plugin routes are exclusive paths, so there is no relay fallback). Declare it only when the model lives at the body top level — vendor formats that nest the model elsewhere should omit `models` and filter inside `decode`. `query` routes have no body and reject the field.
Each `protocols` entry claims a host protocol. A protocol that defines modes must be claimed in object form with an explicit `supports`; the bare-string form is valid only for protocols without modes (`openai_video`). `supports` declares which client request forms the plugin accepts on `openai_responses`: `"stream"` (`stream: true`), `"sync"` (neither flag; the call blocks until the terminal Response), `"background"` (`background: true`; the create returns a pending Response immediately). An unsupported request form is rejected with a 400 at channel selection, before any plugin hook runs and before billing. Retrieval (`GET /v1/responses/:response_id`) is not a mode: every created response is always retrievable.
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, or `setting.task_plugin_key` for type-61 channels.
`{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.
Enabled uploads pre-flight the candidate against the live routing generation and reject channel-type, native-route, or incompatible protocol conflicts (the error names the counterpart plugin). Sharing a model on the same host protocol is allowed. Set `force: true` or `enabled: false` to store the plugin anyway.
@@ -42,6 +44,8 @@ A plugin whose vendor has no LobeHub icon ships its logo as a sidecar file, `ico
`allowedHosts` lists extra hosts that plugin requests may target besides the channel base URL host. Each entry is `host` or `host:port` (IPv6 literals bracketed, for example `[::1]:8080`); schemes, paths, credentials, and queries are rejected. Entries are lowercased and must be unique after normalization. An entry with a port matches only requests to that port (default ports `80`/`443` match requests that omit them).
`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 does not serve vendor paths at their vendor location. It serves a plugin's wire format only on that plugin's own native routes, which carry the plugin's prefix because `/api` is a reserved host namespace and vendor paths would collide across plugins (`/doubao/api/v3/...`, `/ali/api/v1/...`, `/kling/v1/videos/...`), and it authenticates native routes and host protocols with `Authorization: Bearer <token>`. 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. |
@@ -54,8 +58,9 @@ A plugin whose vendor has no LobeHub icon ships its logo as a sidecar file, `ico
| `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. |
`channelTypes` lists the legacy channel types this plugin's driver can drive (for example, sora declares `[55, 1]` because the same OpenAI-type base URL and bearer key serve both chat and video). Every entry equally participates in channel selection, historical `Task.Platform` matching, and the `byChannelType` routing index; the same type value may not appear on two plugins. Third-party plugins normally omit `channelTypes` and live on type-61 "Task Plugin" channels bound by `task_plugin_key`. The previous split identity/compatibility field names are rejected.
`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.
Before channel distribution, the host calls every mode-compatible candidate's
`decodeRequest`. Only candidates returning a valid submit intent for the pinned
@@ -136,10 +141,17 @@ Static consumers can use the compiled display metadata in the marketplace index.
Model-level pricing and save validation select the declared target of an alias;
ambiguous aliases retain the plugin defaults. Runtime validation selects the
executing plugin's final upstream model, including each persisted task during
batch polling. Existing saved expressions are not migrated. Single-plugin models retain their
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.
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
@@ -203,13 +215,16 @@ Descriptions are short, user-facing phrases without trailing punctuation in any
## Host utilities and capabilities
`utils` exposes synchronous host tools. Existing utilities are `unixNow`, `uuid`,
`hmacSHA256`, `jwtSignHS256`, `base64`, `base64URL`, `base64URLDecode`, and
`volcSignV4`; their signatures are declared in `v1.d.ts`.
`hmacSHA256`, `jwtSignHS256`, `base64`, `base64URL`, `base64URLDecode`,
`volcSignV4`, `json.clone`, and `fetch`; their signatures are declared in
`v1.d.ts`.
Plugins using new APIs declare exact versioned capabilities:
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:
```js
requiredCapabilities: ["json-clone@1", "submit-sse-delta@1"]
requiredCapabilities: ["json-order@1"]
```
The host rejects unknown, duplicate, or unsupported capability versions at load.
@@ -218,7 +233,12 @@ supports optional feature detection on hosts that provide this utility; it
does not grant a declared capability. These are additive API v1 extensions;
older strict hosts reject `requiredCapabilities`, so upgrade the host first.
`json-clone@1` provides `utils.json.clone(value)`: an independent JSON snapshot
`json-clone@1` and `submit-sse-delta@1` remain accepted for plugins written for
earlier hosts; new plugins need neither. `json-clone@1` has 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
@@ -227,9 +247,137 @@ 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.
`duration-auto@1` lets a request carry a duration of `-1`, which some vendors
read as "the model picks the length" (Seedance on Volcengine Ark, wan3.0 on
Bailian). Before billing, the host checks every member of the decoded request
at any depth, including vendor parameters passed through `metadata`: a
`duration` or `seconds` member (case, `_` and `-` are ignored, so
`durationSeconds` counts) must be between 0 and 3600. For a plugin that
declares the capability the host also accepts exactly `-1` there; other
negative values and values above the limit are still rejected. Usage facts
are unchanged and must stay non-negative, so the plugin never reports `-1` as
a quantity. A declaring plugin reserves the longest duration the model can
produce when it forwards `-1`, and settles the task on the usage the vendor
reports, so the charge follows the length the model chose. A plugin that
cannot settle on reported usage must not declare it.
Host functions are synchronous and must bound their own work: the JavaScript
execution interrupt cannot preempt a running Go function. Request-scoped data
must remain owned by the host request, not by pooled runtime globals.
`utils.fetch` waits on the network without holding a JavaScript execution slot,
and the wait does not count toward the hook's execution time.
### Reading upstream data with utils.fetch
```ts
utils.fetch(request: {url: string; method?: "GET" | "HEAD"; headers?: Record<string, 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.
- `body` is the parsed JSON when the response parses as JSON (`JSON.parse`
semantics), otherwise the text. A `HEAD` response has an empty string.
- `responseType: "bytes"` returns `body` as an `ArrayBuffer` of the response
bytes instead, for binary data such as the header of a media file read with
a `Range` header; a `HEAD` response 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`.
- Requests use the client and channel proxy of poll requests.
- Redirects are not followed. A 3xx response is returned as it is; fetching its
`Location` is 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`, `headers` and
`responseType`;
- the method is not `GET` or `HEAD` (default `GET`), or the `responseType` is
not `json` (default) or `bytes`;
- 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-Encoding` or `Upgrade`;
- 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. 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). Every other hook, and a dry run,
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.
## Hook arguments and results
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.clone` when 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 `recover` cannot catch and that stops the
whole process. Build a new value, or copy it first (`jsonValue` in 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 `int64` and other numbers
as `float64`; host values returned unchanged keep their original Go type.
Code reading numbers from hook results must accept every numeric type rather
than asserting `float64`. The JSON encoding is the same either way.
## Request body
@@ -242,7 +390,9 @@ Every decoder receives one host-parsed body:
{kind: "none"}
```
`FileReference` contains `{ref, field, filename, mimeType, size}`. The ref is opaque and request-scoped; file bytes, readers, temporary paths, and a second inbound `requestBody` representation are never exposed to JavaScript. The decoder creates a new canonical `{kind:"submit", model, action?, requestBody?, originTaskIds?}` intent. Driver hooks consume that normalized `requestBody` and remain surface-independent.
A JSON `value` enumerates its object members sorted by key, or in the client's order for a plugin that declares `json-order@1`.
`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.
## Request building
@@ -254,13 +404,15 @@ Every decoder receives one host-parsed body:
{ __fileRef: "request_file:input_reference", encoding: "dataUrl", mimeType: "image/png", maxBytes: 20971520 }
```
Placeholder objects may contain only `__fileRef`, `encoding`, `mimeType`, and `maxBytes`. `encoding` is `base64` or `dataUrl`. `__fileRef` must resolve to an uploaded multipart file (`request_file:<field>`). `mimeType` optionally overrides the part `Content-Type` for `dataUrl` (otherwise the part header, else `application/octet-stream`). `maxBytes` is an optional vendor cap; the host also applies `MAX_FILE_DOWNLOAD_MB` (default 64MB) to each file and to the total inlined bytes. Multipart descriptors still stream files with `parts[].fileRef`.
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.
## Origin tasks
Decoders normalize vendor references (draft ids, continuation ids, gateway-issued asset references) into `originTaskIds` as public gateway task IDs. The host never parses vendor request bodies.
The host verifies ownership, requires every referenced task to belong to the same plugin platform set and one enabled channel, pins that channel for the submit (including retries), and injects the resolved rows into driver hooks as `ctx.originTasks` (`taskId`, `upstreamTaskId`, `action`, `status`, and `data`, including the private upstream id). Renderers and presenters never receive `originTasks`.
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`.
## Native surface
@@ -290,11 +442,15 @@ The host registry owns these bindings:
| `openai_video` | create | `POST /v1/videos` | JSON or multipart |
| `openai_video` | retrieve | `GET /v1/videos/:task_id` | none |
| `openai_video` | content | `GET | HEAD /v1/videos/:task_id/content` | none |
| `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.
## Persisted data and driver hooks
@@ -303,6 +459,8 @@ The persisted field remains `task.data`; there is no `task.raw` alias. Driver ho
`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`, jimeng `/jimeng`), and 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`.
## Polling contract
Query and parse hooks use `TaskQueryContext`, not `DriverContext`. The host rebuilds that context from the persisted task row. There is no query-side `requestBody`.
@@ -314,7 +472,8 @@ Query and parse hooks use `TaskQueryContext`, not `DriverContext`. The host rebu
| `action` | Normalized persisted action |
| `model` | `Properties.OriginModelName` |
| `upstreamModel` | `Properties.UpstreamModelName`, falling back to `model` |
| `baseUrl` / `apiKey` / `authHeader` / `auth` | Channel credentials |
| `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` |
@@ -360,7 +519,8 @@ per-call pricing keeps its existing meaning and submission-ratio hook.
### Optional SSE response reader
`meta.submitResponseTypes` defaults to `["json"]`. A plugin that needs SSE
includes `"sse"` and exports `parseSubmitEvent`. `buildSubmitRequest` selects
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.
@@ -383,14 +543,23 @@ when accumulating, rather than mutating host-backed arrays in place.
Idle runtimes survive garbage collection in a pool bounded by the plugin's
configured concurrency. The host creates additional instances on demand and
discards interrupted instances. Cancellation callbacks finish before an instance
is returned to the pool. Hook bindings remain live; the host does not freeze
exported functions or object members. No JavaScript execution slot is held while
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 may copy plain JSON containers without re-encoding their string values;
hook inputs and successive states remain separate snapshots. State size is still
checked against its actual encoded JSON bytes after every event, including
intermediate state that a later event might replace with a smaller value.
The host passes the returned state to the next event as is (see
[Hook arguments and results](#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
@@ -415,10 +584,12 @@ does not add task creation before upstream completion or stream resumption.
### Optional host-managed SSE accumulation
Declaring `submit-sse-delta@1` requires `submitResponseTypes` to include `sse`
and selects `parseSubmitEventDelta` instead of `parseSubmitEvent`. The descriptor
still uses `responseType: "sse"`. Existing plugins without this capability keep
the snapshot hook and its original contract.
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`.
```ts
parseSubmitEventDelta(ctx: DriverContext,
+17 -6
View File
@@ -13,13 +13,14 @@
"baseUrl": {"type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 191},
"channelTypes": {"type": "array", "uniqueItems": true, "items": {"type": "integer", "minimum": 1}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"fetchMode": {"enum": ["per_task", "batch"]}, "allowedHosts": {"type": "array", "uniqueItems": true, "items": {"type": "string"}},
"upstreams": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/upstreamKind"}, "description": "Upstream kinds the driver addresses. vendor is implied; new_api makes the plugin bindable to type-60 New API channels."},
"protocols": {"type": "array", "uniqueItems": true, "items": {"oneOf": [
{"enum": ["openai_video"]},
{"enum": ["openai_video", "openai_image"]},
{"type": "object", "additionalProperties": false, "required": ["name", "supports"], "properties": {"name": {"const": "openai_responses"}, "supports": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["stream", "sync", "background"]}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}},
{"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"const": "openai_video"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}
{"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"enum": ["openai_video", "openai_image"]}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}
]}},
"submitResponseTypes": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["json", "sse"]}},
"requiredCapabilities": {"type": "array", "uniqueItems": true, "items": {"enum": ["json-clone@1", "submit-sse-delta@1"]}},
"requiredCapabilities": {"type": "array", "uniqueItems": true, "items": {"enum": ["json-clone@1", "submit-sse-delta@1", "json-order@1", "duration-auto@1"]}},
"routes": {"type": "array", "items": {"$ref": "#/$defs/route"}}, "usageSchema": {"type": "object", "additionalProperties": {"$ref": "#/$defs/usageField"}}, "usageExamples": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "facts"], "properties": {"label": {"type": "string"}, "facts": {"type": "object"}}}}, "usageProfiles": {"type": "array", "items": {"$ref": "#/$defs/usageProfile"}}, "auth": {}
},
"allOf": [{"if": {"required": ["requiredCapabilities"], "properties": {"requiredCapabilities": {"contains": {"const": "submit-sse-delta@1"}}}}, "then": {"required": ["submitResponseTypes"], "properties": {"submitResponseTypes": {"contains": {"const": "sse"}}}}}],
@@ -103,11 +104,19 @@
"maxBytes": {"type": "integer", "exclusiveMinimum": 0}
}
},
"route": {"type": "object", "additionalProperties": false, "required": ["method", "path", "type", "render"], "properties": {"method": {"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, "path": {"type": "string", "pattern": "^/"}, "type": {"enum": ["submit", "query", "dynamic"]}, "action": {"type": "string"}, "taskIdParam": {"type": "string"}, "decode": {"type": "string"}, "render": {"type": "string"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}, "allOf": [{"if": {"properties": {"type": {"const": "query"}}}, "then": {"allOf": [{"not": {"required": ["decode"]}}, {"not": {"required": ["models"]}}]}}, {"if": {"properties": {"type": {"enum": ["submit", "dynamic"]}}}, "then": {"required": ["decode"]}}]},
"route": {"type": "object", "additionalProperties": false, "required": ["method", "path", "type", "render"], "properties": {"method": {"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, "path": {"type": "string", "pattern": "^/"}, "type": {"enum": ["submit", "query", "dynamic"]}, "action": {"type": "string"}, "taskIdParam": {"type": "string"}, "retainResult": {"type": "boolean"}, "decode": {"type": "string"}, "render": {"type": "string"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}, "allOf": [{"if": {"properties": {"type": {"const": "query"}}}, "then": {"allOf": [{"not": {"required": ["decode"]}}, {"not": {"required": ["models"]}}, {"not": {"required": ["retainResult"]}}]}}, {"if": {"properties": {"type": {"enum": ["submit", "dynamic"]}}}, "then": {"required": ["decode"]}}]},
"upstreamKind": {"enum": ["vendor", "new_api"]},
"upstreamContext": {
"type": "object",
"additionalProperties": false,
"required": ["kind"],
"properties": {"kind": {"$ref": "#/$defs/upstreamKind"}},
"description": "Host-injected on every driver hook context; new_api means the executing channel is a type-60 New API channel with Bearer credentials already applied."
},
"taskQueryContext": {
"type": "object",
"additionalProperties": false,
"required": ["taskId", "publicTaskId", "action", "model", "upstreamModel", "baseUrl", "authHeader", "data", "state"],
"required": ["taskId", "publicTaskId", "action", "model", "upstreamModel", "baseUrl", "authHeader", "upstream", "data", "state"],
"properties": {
"taskId": {"type": "string"},
"publicTaskId": {"type": "string"},
@@ -118,6 +127,7 @@
"apiKey": {"type": "string"},
"authHeader": {"type": "string"},
"auth": true,
"upstream": {"$ref": "#/$defs/upstreamContext"},
"data": true,
"state": true
}
@@ -125,12 +135,13 @@
"batchQueryContext": {
"type": "object",
"additionalProperties": false,
"required": ["baseUrl", "authHeader", "tasks"],
"required": ["baseUrl", "authHeader", "upstream", "tasks"],
"properties": {
"baseUrl": {"type": "string"},
"apiKey": {"type": "string"},
"authHeader": {"type": "string"},
"auth": true,
"upstream": {"$ref": "#/$defs/upstreamContext"},
"tasks": {"type": "array", "items": {"$ref": "#/$defs/taskQueryContext"}}
}
},
+1 -1
View File
@@ -7,7 +7,7 @@ API 密钥编辑页使用一个分组池;数字越小越优先,相同数字
- `custom`:自定义池与模型规则。
- `inherit`:跟随用户可用的全局 Auto 分组。
- `user`:跟随用户当前所属分组。
- “Astra 优先 preferred-group”只是快捷预设;仅在池内已有 `preferred-group` 或本站的 `example-preferred` 时可用,不会自动加入分组或覆盖已有 Astra 规则。
- “Astra 优先指定分组”是快捷预设;先从当前分组池选择目标分组,不会自动加入分组或覆盖已有 Astra 规则。
- 服务端路由预览只计算顺序,不调用上游、不计费。渠道可用性与会话绑定仍会影响实际选择。
- 成功会话绑定按用户、Token、原始模型和会话隔离;新会话按模型规则选择,已有成功绑定优先。失败清除绑定后不会重新采用同一历史日志绑定。