feat: sync deployed v1.0.0-rc.38-custom.42
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent c2b44332d6
commit a5f7b8e586
828 files changed
+122253 -13702

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# Astra 返回模型校验
API 令牌的 `astra_auto_switch` 控制原始模型为 `gpt-6-astra` 的 HTTP `/v1/responses` 错配拦截和安全切换。独立字段 `astra_detection_enabled` 控制检测。关闭检测时不执行 Astra 模型比较、不拦截,并将自动切换保存为关闭;重新打开检测不会自动重新开启切换。检测开启且切换关闭时放行并记录 `mismatch_allowed`,开启时拦截并记录 `mismatch_rejected`。渠道 `responses_astra_model_guard` 字段仅保留存储兼容,界面标记停用,不再决定请求行为。
开启时,明确模型错配可以按现有路由策略安全切换,最多 3 次上游尝试。显式渠道固定、已交付内容、客户端取消、携带 previous_response_id 或 conversation 的请求不会透明切换。每次在 STREAMING_TIMEOUT 内、最多暂存 1 MiB,确认原始上游声明去除首尾空白后等于 `gpt-6-astra`,才允许提交响应头、ping 和内容。网络重试与 strict_session 策略保持现状。
接受后的流实时转发;后续错误模型事件不会交付,转为 `response.failed`,不再透明重试。每次有消耗的上游尝试独立结算,包括被拦截的结果;切换后的新尝试另行计费。沿用持久化尝试账本、usage 收集和结算,不修改价格或退款算法。
无法交付已验证结果时,客户端收到 HTTP 409 和 `response_model_rejected`;若已开始 SSE,则通过 `response.failed` 发送错误。提示包含请求及返回模型、每次消耗独立计费的说明,不包含渠道名称、地址或鉴权信息。拒收后仍收集 usage 以完成账本结算。
管理员日志详情的 `admin_info.request_details.model_guard.attempts` 保存尝试序号、渠道 ID、期望及返回模型、原因、耗时、是否交付以及已经取得的 usage。没有取得 usage 表示未知成本,不表示上游没有收费。普通用户日志不返回此字段。
请求所属用户可以查看尝试历史、实际费用、模型声明与校验结果;渠道 ID 仅管理员可见。尝试 API 增加 `charged`(已结算且费用大于零)及 `charge_explanation`。待结算尝试不会被误标为已扣费。
验证入口:`relay/response_model_test.go`;前端 `admin-request-details.test.tsx` 和 `responses-websocket-setting.test.tsx`。隔离运行验收还覆盖真实 HTTP 转发、429 混合重试、退款及单次扣费、超时关闭连接、ping 门控和客户端断线排空。
本功能仅验证上游响应的模型声明,不能识别伪报模型。首次启动新增令牌列并以迁移标记将已有令牌设为开启;新建令牌默认检测开启、自动切换关闭;已有令牌迁移后保留检测开启及原切换设置,重复启动保留用户选择。旧 Redis 哈希补齐新字段时不覆盖实时额度。启用并保存需要费用确认;令牌页的首次费用提示通过用户 setting 中的 `astra_cost_notice_ack` 记录已读。API 更新省略开关时保留原值,显式 false 正常持久化。
鉴权继续使用现有令牌所有权检查与认证缓存失效机制,新增审计字段不含凭据。安全参考:[OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)、[OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)。
+9
View File
@@ -0,0 +1,9 @@
# custom.19: 错误日志实际分组
- 修复错误日志使用用户默认分组代替本次实际分组的问题。优先采用 RelayInfo 的实际计费分组,兼容 Auto/统一池及固定分组。
- 未选择到渠道时,分组留空,不再伪装为 default。
- 历史错误日志在 API 输出时,依据保存的 request_policy 中匹配渠道的分组纠正显示;在用户权限裁剪之前完成,仍不泄露私有路由信息。
- 无证据的旧错误记录不猜测分组。消费日志、金额、token 用量和数据库中的历史记录不改写。
- 历史记录的数据库分组筛选仍按原存储值工作;新的错误记录保存正确分组,筛选正常。
测试:实际错误分组、重试分组、无渠道、固定分组;历史读时投影及普通用户日志隐私;真实候选 HTTP 回归;旧版 Astra 守卫/逐次结算保持不变。
+9 -5
View File
@@ -54,11 +54,15 @@ query is redacted before request logging.
Deployment boundaries and concurrency environment variables are documented in
[v1.md](./v1.md#generic-task-management-api).
The host treats `protocols.openai_video.render` as a standard DTO, not an arbitrary
JSON passthrough. Unknown top-level fields and legacy `task_id` are removed,
`id` is forced to the public task id, and case-insensitive `url` entries are
removed from metadata. Provider output URLs belong only behind artifact
capabilities.
The host requires `protocols.openai_video.render` to return a JSON object and
preserves provider extensions, including output URLs and metadata. Plugins may
return `task.data` to expose the latest persisted upstream snapshot. The host
still overwrites `id`, `object`, `model`, `status`, `progress`, `created_at`, and
`completed_at`, and removes legacy `task_id`. This is field-level projection,
not byte-for-byte HTTP forwarding: known private task IDs are replaced before
rendering, polling may redact inline video data, and retrieval reads the latest
saved snapshot rather than issuing a new upstream request. Artifact content
endpoints remain available for proxied downloads.
Provider-authenticated content URLs must use the channel base host or a
plugin-declared `meta.allowedHosts` entry. A public dynamic CDN URL may instead
+35 -5
View File
@@ -1,4 +1,20 @@
export type JSONValue = null | boolean | number | string | readonly JSONValue[] | {readonly [key: string]: JSONValue};
export type HostCapability = "json-clone@1" | "submit-sse-delta@1";
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;
/** Independent native JS containers; at most 1 MiB encoded JSON, depth 32 and 32,768 nodes. */
json: {clone<T extends JSONValue>(value: T): MutableJSON<T>};
unixNow(): number;
uuid(): string;
hmacSHA256(message: string, secret: string): string;
jwtSignHS256(claims: Record<string, JSONValue>, secret: string): string;
base64(value: string): string;
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>;
}
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}>;
export type DecodedBody =
@@ -20,15 +36,20 @@ export type ProtocolClaim =
| {name: "openai_responses"; supports: readonly ResponsesMode[]; models?: readonly string[]}
| {name: "openai_video"; models?: readonly string[]};
export type LocalizedText = string | ({ en: string } & Record<string, string>);
export type UsageFieldSchema = {type: "number"; unit: "second" | "count" | "token" | "credit"; description?: LocalizedText} | {type: "boolean"; description?: LocalizedText} | {enum: readonly string[]; description?: LocalizedText; enumLabels?: Readonly<Record<string, LocalizedText>>};
export type UsageExample = {label: string; facts: Readonly<Record<string, string | number>>};
export interface Meta {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[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
export type UsageFieldSchema =
| {type: "number"; unit: "count"; unitLabel?: LocalizedText; description?: LocalizedText}
| {type: "number"; unit: "second" | "token" | "credit"; unitLabel?: never; description?: LocalizedText}
| {type: "boolean"; unitLabel?: never; description?: LocalizedText}
| {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 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 type HookHTTPResponse = {readonly status: number; readonly headers: Readonly<Record<string, string>>}
export interface RequestDescriptor {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 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}
export interface NormalizedTaskResult {taskId?: string; status: "NOT_START" | "SUBMITTED" | "QUEUED" | "IN_PROGRESS" | "SUCCESS" | "FAILURE" | "UNKNOWN"; progress?: string; reason?: string; url?: string; remoteUrl?: string; completionTokens?: number; totalTokens?: number}
export interface TaskArtifact {key: string; type: "video" | "audio" | "image" | "file"; mimeType?: string}
@@ -39,6 +60,15 @@ export declare const protocols: {
openai_video?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; render(ctx: unknown, task: TaskView): unknown};
};
export declare function buildSubmitRequest(ctx: DriverContext): RequestDescriptor;
export interface SubmitEvent {event: string; id: string; data: string}
export type JSONPath = readonly (string | number)[];
export type JSONChange =
| {op: "set" | "append"; path: JSONPath; value: JSONValue}
| {op: "appendText"; path: JSONPath; value: string};
export interface SubmitEventDeltaResult {changes: readonly JSONChange[]; state: JSONValue; done: boolean}
/** With submit-sse-delta@1, state is small control data; changes build the separate response body. */
export declare function parseSubmitEventDelta(ctx: DriverContext, event: SubmitEvent, previousState: JSONValue | null): SubmitEventDeltaResult;
export declare function parseSubmitEvent(ctx: DriverContext, event: SubmitEvent, previousState: JSONValue | null): {state: JSONValue; done: boolean};
export declare function parseSubmitResponse(ctx: DriverContext, response: UpstreamResponse): {taskId: string; taskData?: unknown; immediate?: NormalizedTaskResult; state?: unknown};
export declare function buildQueryRequest(ctx: TaskQueryContext): RequestDescriptor;
export declare function buildBatchQueryRequest(ctx: BatchQueryContext, tasks: readonly TaskQueryContext[]): RequestDescriptor;
@@ -46,6 +76,6 @@ export declare function parseTaskResult(ctx: TaskQueryContext, body: unknown, re
export declare function parseBatchResult(ctx: BatchQueryContext, body: unknown, response: HookHTTPResponse): readonly (NormalizedTaskResult & {taskId: string; data?: unknown; state?: unknown})[];
export declare function extractUsage(ctx: DriverContext & {usagePurpose?: "facts" | "billing_ratios"}): Readonly<Record<string, string | number | boolean>> | null;
export declare function extractUsageOnSubmit(ctx: DriverContext, taskData: unknown): Readonly<Record<string, string | number | boolean>> | null;
export declare function extractUsageOnComplete(task: TaskView, result: NormalizedTaskResult, data: unknown): Readonly<Record<string, string | number | boolean>> | null;
export declare function extractUsageOnComplete(task: TaskQueryContext, result: NormalizedTaskResult, data: unknown): Readonly<Record<string, string | number | boolean>> | null;
export declare function listArtifacts(task: {taskId: string; status: string; action: string; data: unknown; producerVersion: string}): readonly TaskArtifact[];
export declare function buildContentRequest(ctx: DriverContext & {artifactKey: string; data: unknown; state?: unknown; upstreamTaskId: string; clientRequest: {method: "GET" | "HEAD"; headers: Readonly<Record<string, string>>}}): RequestDescriptor;
+247 -4
View File
@@ -28,9 +28,9 @@ export const meta = {
Each `protocols` entry claims a host protocol. A protocol that defines modes must be claimed in object form with an explicit `supports`; the bare-string form is valid only for protocols without modes (`openai_video`). `supports` declares which client request forms the plugin accepts on `openai_responses`: `"stream"` (`stream: true`), `"sync"` (neither flag; the call blocks until the terminal Response), `"background"` (`background: true`; the create returns a pending Response immediately). An unsupported request form is rejected with a 400 at channel selection, before any plugin hook runs and before billing. Retrieval (`GET /v1/responses/:response_id`) is not a mode: every created response is always retrievable.
`{name, models}` still narrows that protocol's endpoint bindings to a subset of `meta.models` and composes with `supports`. Unlisted models never enter the plugin on that protocol path — they fall through to the built-in Go relay. Cross-plugin endpoint conflicts are judged on the narrowed set, so two plugins may share one protocol path by claiming disjoint model sets.
`{name, models}` still narrows that protocol's endpoint bindings to a subset of `meta.models` and composes with `supports`. Unlisted models never enter the plugin on that protocol path — they fall through to the built-in Go relay. Multiple plugins may claim the same model on the same host protocol path, including type-61 plugins and plugins with legacy `channelTypes`. Candidates are ordered by ascending plugin key. The selected channel determines the executing plugin: its channel type for legacy channels, or `setting.task_plugin_key` for type-61 channels.
Enabled uploads pre-flight the candidate against the live routing generation and reject the first channel-type, native-route, or protocol-model conflict (the error names the counterpart plugin). Set `force: true` or `enabled: false` to store the plugin anyway.
Enabled uploads pre-flight the candidate against the live routing generation and reject channel-type, native-route, or incompatible protocol conflicts (the error names the counterpart plugin). Sharing a model on the same host protocol is allowed. Set `force: true` or `enabled: false` to store the plugin anyway.
`endpoints`, `routes[].renderer`, global `resolveRequest`, global `renderError`, and global `renderers` are rejected. `parseSubmitResponse` returns only `{taskId, taskData, immediate?, state?}`; `clientResponse` is rejected.
@@ -57,13 +57,97 @@ A plugin whose vendor has no LobeHub icon ships its logo as a sidecar file, `ico
`channelTypes` lists the legacy channel types this plugin's driver can drive (for example, sora declares `[55, 1]` because the same OpenAI-type base URL and bearer key serve both chat and video). Every entry equally participates in channel selection, historical `Task.Platform` matching, and the `byChannelType` routing index; the same type value may not appear on two plugins. Third-party plugins normally omit `channelTypes` and live on type-61 "Task Plugin" channels bound by `task_plugin_key`. The previous split identity/compatibility field names are rejected.
Before channel distribution, the host calls every mode-compatible candidate's
`decodeRequest`. Only candidates returning a valid submit intent for the pinned
model remain eligible. If all reject the request, the host returns the candidate
failures, grouping identical messages with their plugin keys. A single candidate
retains its original failure message. After channel selection, the executing plugin decodes again to build
its own normalized request. Decoders must therefore tolerate repeated calls.
Channel-type ownership remains unique; sharing models does not relax this rule.
Shared models may use different usage schemas and prices for each plugin.
Administrators can save a provider expression in
`billing_setting.plugin_billing_expr`, keyed by `<pluginKey>::<model>`.
Without an override, the provider inherits the model-level expression. If the
effective expression references a literal `u()` key absent from that provider's
selected schema, the shared model is unconfigured for that provider; submission
returns 400 `model_price_error` until compatible pricing is saved. Pricing pages
show the provider variants and their configuration status. Model-level usage
labels continue to come from the first plugin in ascending key order. Saving a
model expression checks every candidate without its own override in the same
draft. Provider overrides are not included in model-price synchronization.
Saved overrides remain visible and effective when only one provider is left.
An override whose plugin is unavailable or no longer declares the model is
marked `stale` in the administration snapshot. Its unchanged value can be kept
while saving other prices, or explicitly removed; adding or changing an override
still requires a current plugin/model binding. Public variants distinguish
`ratio` fallback pricing from `tiered_expr`, so a provider using a configured
per-call price is not marked unconfigured merely because it has no expression.
Numeric `usageSchema` fields declare a host-owned unit of `second`, `count`, `token`, or `credit`. Boolean fields declare `{type: "boolean"}`.
Number fields with `unit: "count"` may additionally declare `unitLabel`, an
optional LocalizedText display unit. This distinguishes counted objects without
changing the canonical unit, usage facts, expression keys, prices, or settlement:
```js
image_count: {
type: "number",
unit: "count",
unitLabel: {en: "image", zh: "张", "zh-TW": "張"},
description: {en: "Image generation unit price", zh: "图片生成单价"},
}
```
Labels are short unit names or counting words, such as `image` / `张` or
`song` / `首`, without prices, quantities, slashes, or trailing punctuation.
They follow the same locale normalization, required English fallback, and
256-rune-per-locale limit as field descriptions. A bare string is normalized to
an English map. The UI uses the current language, its primary language, then
English; omitted labels retain the host's existing count-unit display.
Labels are plugin data, not frontend translation keys. They apply equally to
`usageSchema` and `usageProfiles[].schema`, including pricing, calculators,
plugin details, and usage-log pricing. Other units, booleans, and enums cannot
declare `unitLabel`; token prices retain their per-million display scale.
This is an additive extension of plugin API version 1. Updated hosts accept
existing plugins unchanged; older hosts reject the unknown `unitLabel` property.
Upgrade the host before installing a plugin that declares it.
`meta.usageExamples` is an optional display-only list of pricing examples. Each `label` is a human-readable spec name. Each `facts` object must be a complete vector over `usageSchema`: every declared key present, no undeclared keys, and values that pass the same validation as usage facts. Plugins whose schema contains a `unit: "token"` number field must declare at least one example. Examples never affect billing. Labels are spec names such as `std · 10s`, not storefront prices.
### Usage profiles by model
`meta.usageProfiles` optionally contains `{models, schema, examples?}` entries.
Each profile provides the complete schema and display examples for its models,
replacing `usageSchema` and `usageExamples` without merging or inheriting them.
Unmatched models use the plugin defaults. Models must use the declared spelling
from `meta.models` and belong to at most one profile. Empty model lists and
missing/null schemas are rejected; an empty schema object is valid. Token-unit
profiles require their own examples. The same field, description, and example
validation applies inside a profile as at the plugin level.
Define constants by use case (for example `IMAGE_USAGE_SCHEMA` and
`VIDEO_USAGE_SCHEMA`) and reference them from the profiles. These are ordinary
JavaScript constants: the host executes the module before reading `meta`.
Static consumers can use the compiled display metadata in the marketplace index.
Model-level pricing and save validation select the declared target of an alias;
ambiguous aliases retain the plugin defaults. Runtime validation selects the
executing plugin's final upstream model, including each persisted task during
batch polling. Existing saved expressions are not migrated. Single-plugin models retain their
existing submission checks; shared models additionally enforce the per-provider
field-reference compatibility rule above. Normal evaluation and quota safety
checks continue to apply. Saving an expression uses its model's selected schema.
This remains plugin API version 1. New hosts accept older plugins unchanged;
older hosts reject the new `usageProfiles` field. Upgrade the host to a build
supporting profiles before installing a plugin that declares them.
## LocalizedText
`meta.description` and each `usageSchema` field `description` accept LocalizedText. A bare string is equivalent to `{en: <string>}`. A map must include a non-empty `en` value. The host normalizes both forms to a map; API responses always emit an object.
`meta.description` and each `usageSchema` field `description` and `unitLabel` accept LocalizedText. A bare string is equivalent to `{en: <string>}`. A map must include a non-empty `en` value. The host normalizes both forms to a map; API responses always emit an object.
Enum fields may additionally declare `enumLabels`, a map from enum values to LocalizedText display names:
@@ -116,6 +200,37 @@ Descriptions are short, user-facing phrases without trailing punctuation in any
- For example, replace `Requested video duration in seconds.` / `请求的视频时长,单位为秒。` with `Video generation unit price` / `视频生成单价`; replace `Whether audio is generated. Default true.` / `是否生成音频。默认为 true。` with `Whether audio is generated` / `是否生成音频`.
- These are authoring conventions for plugin metadata, not additional runtime validation rules. Existing `LocalizedText` wire types, usage quantities, enum values and billing semantics are unchanged; a numeric field still contains usage, not a price.
## Host utilities and capabilities
`utils` exposes synchronous host tools. Existing utilities are `unixNow`, `uuid`,
`hmacSHA256`, `jwtSignHS256`, `base64`, `base64URL`, `base64URLDecode`, and
`volcSignV4`; their signatures are declared in `v1.d.ts`.
Plugins using new APIs declare exact versioned capabilities:
```js
requiredCapabilities: ["json-clone@1", "submit-sse-delta@1"]
```
The host rejects unknown, duplicate, or unsupported capability versions at load.
Omitting the field preserves existing behavior. `utils.hasCapability(name)`
supports optional feature detection on hosts that provide this utility; it
does not grant a declared capability. These are additive API v1 extensions;
older strict hosts reject `requiredCapabilities`, so upgrade the host first.
`json-clone@1` provides `utils.json.clone(value)`: an independent JSON snapshot
with native mutable JavaScript objects and arrays. It accepts plain objects,
arrays, null, booleans, finite numbers and strings. Undefined, functions,
BigInt, dates, sparse arrays and cyclic structures are rejected. Object keys
such as `__proto__` remain ordinary own data properties. The tool uses the
host's JSON number and string normalization and preserves explicit zero and
false values. Limits are 1 MiB of encoded JSON, 32 levels below the root and
32,768 total nodes, including containers and scalar values. It performs no I/O.
Host functions are synchronous and must bound their own work: the JavaScript
execution interrupt cannot preempt a running Go function. Request-scoped data
must remain owned by the host request, not by pooled runtime globals.
## Request body
Every decoder receives one host-parsed body:
@@ -180,7 +295,7 @@ The host registry owns these bindings:
`background: true` on create returns the pending Response immediately (host-synthesized, no plugin hook). Retrieval renders via `renderFinal` only at terminal status. Plugins declaring `"background"` need no create-time hook; the deliverable is rendered at retrieval via `renderFinal`. Plugins cannot observe the `background` field. Unlike the upstream OpenAI API, a plugin that does not declare `"stream"` rejects `stream: true` with a 400; the error names the supported forms.
Protocol media uses host-injected `ctx.artifacts[key].url`. Provider URLs from `task.data` are not protocol output. OpenAI Video projections are DTO-whitelisted and the host overwrites identity, lifecycle, timestamps, and removes URL-like metadata.
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
@@ -224,3 +339,131 @@ The host classifies the HTTP status before trusting a non-terminal parse:
A valid 2xx non-terminal parse resets `PollFailures` to 0. After `TASK_POLL_MAX_FAILURES` (default 20) consecutive failures the task becomes `FAILURE` and follows the existing refund chain. The 24h `TASK_TIMEOUT_MINUTES` sweep remains the outer deadline.
Marketplace index v1 plugin entries also accept optional `sortPriority` and `website` fields. Each source is sorted independently by descending priority and ascending key. Missing or invalid priorities fall back to `0`; invalid websites are hidden. Installed plugins and channel binding options use plugin metadata. Website links open only on user interaction; the host does not fetch them.
## Immediate completion and submission streams
A driver may return `immediate: {status: "SUCCESS" | "FAILURE", ...}` from
`parseSubmitResponse`. For immediate success the host calls the existing
`extractUsageOnComplete(ctx, result, taskData)` with a `TaskQueryContext`, using
both model identities, the submitted task ID, parsed data and returned plugin
state. The same model-specific usage validation as polling applies. Plugins
return usage quantities, never host quota or a charge.
For task expressions the host overlays valid completion facts onto the frozen
submission facts, evaluates the frozen expression and reserves any increase
before persistence. The final quota, usage and tier are recorded before the
single BillingSession settlement. Zero usage is retained. Invalid facts or an
expression evaluation failure retain the reservation and produce a warning.
Immediate failure settles to zero. Terminal tasks are not polled. Legacy
per-call pricing keeps its existing meaning and submission-ratio hook.
### Optional SSE response reader
`meta.submitResponseTypes` defaults to `["json"]`. A plugin that needs SSE
includes `"sse"` and exports `parseSubmitEvent`. `buildSubmitRequest` selects
`responseType: "sse"` on its request descriptor (default `"json"`); the selected
type must be declared. This optional v1 manifest field requires a supporting
host: older strict hosts reject it instead of silently misreading the stream.
```ts
parseSubmitEvent(ctx: DriverContext,
event: {event: string; id: string; data: string},
previousState: JSONValue | null
): {state: JSONValue; done: boolean}
```
The host accepts `text/event-stream`, joins multiple `data:` lines with newlines,
handles CRLF, comment heartbeats, event names (default `message`) and event IDs.
It does not parse provider JSON, interpret provider completion markers or sum
usage. The plugin does those operations and returns explicit JSON state on every
call; pooled runtimes do not guarantee module globals survive between events.
Treat `previousState` as read-only JSON input. Construct owned arrays/objects
when accumulating, rather than mutating host-backed arrays in place.
Idle runtimes survive garbage collection in a pool bounded by the plugin's
configured concurrency. The host creates additional instances on demand and
discards interrupted instances. Cancellation callbacks finish before an instance
is returned to the pool. Hook bindings remain live; the host does not freeze
exported functions or object members. No JavaScript execution slot is held while
waiting for the next upstream event.
The host may copy plain JSON containers without re-encoding their string values;
hook inputs and successive states remain separate snapshots. State size is still
checked against its actual encoded JSON bytes after every event, including
intermediate state that a later event might replace with a smaller value.
A complete, blank-line-delimited event whose hook returns `done: true` terminates
the reader and closes the upstream response. The last state is passed as
`response.body` to the existing `parseSubmitResponse`, retaining the upstream
HTTP status and headers. The normal immediate-completion path then persists,
settles and presents the result. SSE state is request-local; durable plugin
state is still only the `state` returned by `parseSubmitResponse`.
EOF without `done`, invalid framing/state, read failures, hook failures and size
limits fail submission. Events, accumulated JSON state and persisted task data
are each limited to 1 MiB; data is never truncated. The existing streaming idle
timeout applies (30 seconds if unset), as do the submission context deadline
and per-hook execution timeout. Once a successful SSE HTTP response is accepted,
failures are non-retryable, including 5xx-shaped local errors: starting the
provider operation again could duplicate billable work.
No upstream events are forwarded to clients. Native presenters still return
JSON, and Responses uses its existing presentation flow after the durable
submission barrier. Native cancellation closes the upstream reader; Responses
retains its existing independently bounded submission context. This interface
does not add task creation before upstream completion or stream resumption.
### Optional host-managed SSE accumulation
Declaring `submit-sse-delta@1` requires `submitResponseTypes` to include `sse`
and selects `parseSubmitEventDelta` instead of `parseSubmitEvent`. The descriptor
still uses `responseType: "sse"`. Existing plugins without this capability keep
the snapshot hook and its original contract.
```ts
parseSubmitEventDelta(ctx: DriverContext,
event: {event: string; id: string; data: string},
previousState: JSONValue | null
): {changes: JSONChange[]; state: JSONValue; done: boolean}
```
`state` contains control data needed to interpret the next event, limited to
64 KiB encoded JSON. Only this snapshot is passed back to JavaScript. The host
maintains a separate result, initially null, and applies `changes` in order:
```js
{op: "set", path: [], value: {text: "", images: []}}
{op: "appendText", path: ["text"], value: "A new text fragment"}
{op: "append", path: ["images"], value: {url: "https://cdn.example/image.png"}}
```
- `set` replaces the root at an empty path, replaces an existing array element,
or adds/replaces an object member. Parent containers must already exist.
- `append` appends one JSON value to an existing array.
- `appendText` appends a string to an existing string. The host retains text in
Go and does not send accumulated text back to the plugin.
- Paths are arrays of string object keys or non-negative integer array indices.
Indices must address existing elements; sparse arrays and implicit parent
creation are unsupported. Keys are data, never property-access expressions.
Each result contains exactly `changes`, `state`, and boolean `done`. Each change
contains exactly `op`, `path`, and `value`. There are at most 256 changes per
event, 32 path segments, 32 levels below the result root, 32,768 result nodes,
and 1 MiB of encoded result JSON. Size accounting includes escaped keys and
strings, punctuation and replacements, and is checked after every operation.
An oversized intermediate operation fails even if a later operation would
shrink the result. Invalid operations invalidate the submission; partial
results are never published.
The host counts the encoded sizes of changed values rather than encoding the
complete accumulated result on every event. At `done: true`, it materializes the
ordinary JSON result and verifies its actual encoding against the recorded
size before passing it as `response.body` to `parseSubmitResponse`. The result
then follows the same persistence, usage validation and settlement path as the
snapshot mode. State and builders belong to one request and are released on
completion, cancellation or error; no JS slot is held during upstream I/O.
The plugin still interprets vendor payloads, decides which fields to update,
distinguishes cumulative usage from increments, and decides completion. Alibaba
uses this mode for interleaved image generation, keeping only content indices,
text adjacency and finish reasons in its control state.
+34 -1
View File
@@ -18,15 +18,47 @@
{"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}}}}
]}},
"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"}}}}, "auth": {}
"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"]}},
"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"}}}}}],
"$defs": {
"jsonChange": {
"type": "object", "additionalProperties": false, "required": ["op", "path", "value"],
"properties": {
"op": {"enum": ["set", "append", "appendText"]},
"path": {"type": "array", "maxItems": 32, "items": {"oneOf": [{"type": "string"}, {"type": "integer", "minimum": 0}]}},
"value": true
},
"allOf": [{"if": {"properties": {"op": {"const": "appendText"}}}, "then": {"properties": {"value": {"type": "string"}}}}]
},
"submitEventDeltaResult": {
"type": "object", "additionalProperties": false, "required": ["changes", "state", "done"],
"properties": {
"changes": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/jsonChange"}},
"state": true,
"done": {"type": "boolean"}
}
},
"usageProfile": {
"type": "object",
"additionalProperties": false,
"required": ["models", "schema"],
"properties": {
"models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"schema": {"type": "object", "additionalProperties": {"$ref": "#/$defs/usageField"}},
"examples": {"type": "array", "maxItems": 16, "items": {"$ref": "#/properties/usageExamples/items"}}
},
"description": "Complete usage metadata for declared models. The host validates model membership, unique profile ownership and examples, including required token-unit examples."
},
"usageField": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {"enum": ["number", "boolean"]},
"unit": {"enum": ["second", "count", "token", "credit"]},
"unitLabel": {"$ref": "#/$defs/usageLabel", "description": "Optional localized display unit for number fields with count unit. Does not change usage quantities or billing."},
"enum": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}},
"description": {"$ref": "#/$defs/usageLabel"},
"enumLabels": {
@@ -35,6 +67,7 @@
"description": "Optional display names keyed by declared enum values; the host validates membership. Missing entries display the original value."
}
},
"allOf": [{"if": {"required": ["unitLabel"]}, "then": {"required": ["type", "unit"], "properties": {"type": {"const": "number"}, "unit": {"const": "count"}}}}],
"oneOf": [
{"required": ["enum"], "not": {"anyOf": [{"required": ["type"]}, {"required": ["unit"]}]}},
{"required": ["type", "unit"], "properties": {"type": {"const": "number"}}, "not": {"anyOf": [{"required": ["enum"]}, {"required": ["enumLabels"]}]}},
+45
View File
@@ -0,0 +1,45 @@
# Token 路由与 Astra 逐次结算(custom.18)
## 路由配置
API 密钥编辑页使用一个分组池;数字越小越优先,相同数字轮询。模型规则精确匹配原始请求模型名,规则分组先于默认池内其余分组;不支持的分组会按用户当前权限过滤。
- `custom`:自定义池与模型规则。
- `inherit`:跟随用户可用的全局 Auto 分组。
- `user`:跟随用户当前所属分组。
- “Astra 优先 preferred-group”只是快捷预设;仅在池内已有 `preferred-group` 或本站的 `example-preferred` 时可用,不会自动加入分组或覆盖已有 Astra 规则。
- 服务端路由预览只计算顺序,不调用上游、不计费。渠道可用性与会话绑定仍会影响实际选择。
- 成功会话绑定按用户、Token、原始模型和会话隔离;新会话按模型规则选择,已有成功绑定优先。失败清除绑定后不会重新采用同一历史日志绑定。
启动时,主节点迁移所有未删除的存量 Token,保留旧配置的有效顺序/并列优先级与继承模式,不注入任何模型规则。重复启动不会覆盖新策略。Token 缓存使用新版本命名空间,避免旧缓存绕过策略迁移。
## Astra 守卫与计费
仅针对 HTTP `POST /v1/responses` 且原始模型严格为 `gpt-6-astra`:
- 守卫开启、明确返回错误模型、尚未交付客户端时,可以切换至未试过的渠道,总上游尝试最多 3 次(不受普通 RetryTimes=1 的两次限制)。
- 缺失模型、确认超时不触发这个特殊重试;固定渠道、取消请求、已交付响应、`previous_response_id` 或上游 `conversation` 不进行该重放。
- 每个候选渠道自己的守卫开关仍有效。确认阶段共享默认 300 秒预算,而不是每次重新开始。
- 每次实际消耗独立按请求的 Astra 价格和当次实际分组倍率结算。错误返回 Luna 的已消耗尝试也计费;最终成功只代表最后一次交付成功,不代表前面的尝试免费。
- 被拒绝的响应不向客户端交付,后台最多收集 60 秒 usage。实际 usage(含缓存命中)优先;缺失 usage 时按实际准备发送的输入及观察到的输出估算,不虚构缓存命中。
- 除明确模型不匹配外,其他守卫失败只有在观察到实际 usage/输出时收费;纯超时、无输出无 usage 的尝试退回预扣。
- 保留 custom.17 对 `stream=false` 上游返回 SSE 的兼容处理。
## 账本与日志
`relay_attempts` 保存预扣、检查点、结算状态与日志 outbox,不保存提示词、输出正文或上游密钥。钱包/订阅、Token、用户、渠道和开启导出的看板统计在同一事务结算。唯一 attempt key 防止重复扣费/重复日志;SQLite 写冲突重试的是整个已回滚事务。
失效预扣由恢复任务每 5 秒扫描;活跃请求续租,进程中断后通常最多等待 10 分钟租约过期,再按最后持久化的 usage 检查点结算或退款。独立日志库恢复后可重放 outbox,不重复财务扣款。
用量日志的路由尝试面板显示请求/返回模型、实际分组、费用依据、缓存用量、估算标记和结算状态。面板的合计是各次已结算费用之和,不是再次收费。普通用户只能访问自己的记录,渠道 ID 仅管理员可见。
## 验证范围
- SQLite、MySQL 8.4、PostgreSQL 18.6:新库、存量升级、重复迁移、幂等结算、订阅、日志 outbox。
- SQLite 并发预扣/结算压力回归;钱包、Token 与渠道统计一致。
- 真实候选 HTTP:两错一对、三次都错、流式切换、固定渠道、previous_response_id、守卫关闭、缺失模型无消耗、缺失 usage 估算、交付后错误模型不重放。
- 实际 dashboard 鉴权下验证管理员/本人/其他用户记录隔离、看板总额、零上游路由预览。
- 前端类型检查、目标 lint 和 276 项组件/表单回归。
- 生产库隔离副本:32 个存量 Token 逐个对比旧配置,重复启动策略哈希不变。
ClickHouse 不是这次验证的数据库范围。