feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
@@ -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)。
|
||||
@@ -0,0 +1,9 @@
|
||||
# custom.19: 错误日志实际分组
|
||||
|
||||
- 修复错误日志使用用户默认分组代替本次实际分组的问题。优先采用 RelayInfo 的实际计费分组,兼容 Auto/统一池及固定分组。
|
||||
- 未选择到渠道时,分组留空,不再伪装为 default。
|
||||
- 历史错误日志在 API 输出时,依据保存的 request_policy 中匹配渠道的分组纠正显示;在用户权限裁剪之前完成,仍不泄露私有路由信息。
|
||||
- 无证据的旧错误记录不猜测分组。消费日志、金额、token 用量和数据库中的历史记录不改写。
|
||||
- 历史记录的数据库分组筛选仍按原存储值工作;新的错误记录保存正确分组,筛选正常。
|
||||
|
||||
测试:实际错误分组、重试分组、无渠道、固定分组;历史读时投影及普通用户日志隐私;真实候选 HTTP 回归;旧版 Astra 守卫/逐次结算保持不变。
|
||||
@@ -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
|
||||
|
||||
Vendored
+35
-5
@@ -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
@@ -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.
|
||||
@@ -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"]}]}},
|
||||
|
||||
@@ -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 不是这次验证的数据库范围。
|
||||
Reference in new issue
Block a user