feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
+21
-11
@@ -132,11 +132,11 @@ Gemini 音频输入使用旧结算实际采用的美元单价生成 `ai`,不
|
||||
转换生成音频请求与纯文本请求两个分支。音频分支使用 `max(len - ai, 0)` 计量普通输入,避免纯文本
|
||||
分支中引用的缓存变量改变音频费用。Gemini 文本结算仍保留缓存/音频交叠后的输入余量零下限。
|
||||
只读 `billing_details` 为预览提供实际音频单价、图片数量和请求倍率规则,不成为新的持久化价格来源。
|
||||
同一计费名在 Gemini/兼容音频入口存在相互冲突的单价,或活动图片渠道采用不同请求倍率时,
|
||||
同一计费名在 Gemini/兼容音频入口存在相互冲突的单价时,
|
||||
转换返回该具体冲突,不能静默选择其中一条规则覆盖其他入口。
|
||||
|
||||
图片按次价格转换为 `tier("image", fixed(price)) * image_count`,再追加旧尺寸、质量及适用的
|
||||
prompt_extend 条件倍率。DALL·E 尺寸/质量规则按原始请求模型名生成,模型映射不会额外引入这些规则。
|
||||
图片按次价格转换为 `tier("image", fixed(price)) * image_count`,再追加旧尺寸、质量条件倍率。
|
||||
DALL·E 尺寸/质量规则按原始请求模型名生成,模型映射不会额外引入这些规则。
|
||||
OpenAI 已于 2026-05-12 下线 DALL·E 2/3;其校验、默认值和倍率保留在独立 legacy 文件,
|
||||
仅用于历史配置及兼容上游。转换从 DTO 的旧计费逻辑获取倍率,不维护第二份价格表。
|
||||
图片 token 定价不额外乘数量。所有金额和倍率固化在表达式中,不再叠加旧 OtherRatios。
|
||||
@@ -145,13 +145,16 @@ OpenAI 已于 2026-05-12 下线 DALL·E 2/3;其校验、默认值和倍率保
|
||||
|
||||
`image_count` 是独立于 token 的计费数量。省略上下文时默认 1;提供的数量必须是 1 到
|
||||
`dto.MaxImageN`(128)的整数。固定价格语法允许它作为乘数,仍禁止用 token 乘固定价格。
|
||||
图片入口只解析一次 provider 计费标量并随请求携带。Ali 遵循 `parameters.n → n → 1` 的优先级:
|
||||
缺失或 `null` 的嵌套数量回退顶层,显式 `parameters.n=0` 返回 400;顶层 `n=0` 仍兼容为 1。
|
||||
Ali JSON 和 multipart 请求都明确发送该生效数量。每次渠道重试或参数覆盖之后,再校验最终上游数量并在发送前补足预留,
|
||||
图片入口只解析一次 provider 计费标量并随请求携带。数量来自顶层 `n`(`n=0` 兼容为 1);
|
||||
`parameters.n` 仍在入口校验范围,但不再覆盖计费数量。每次渠道重试或参数覆盖之后,再校验最终上游数量并在发送前补足预留,
|
||||
图片钱包预留使用原子余额检查,不能通过增加数量形成欠费后继续提交。
|
||||
`estimated_image_count` 保存本次发送前的数量;JSON/multipart 图片参数上下文只保留计费需要的标量,不保留图片或提示词内容。
|
||||
结算使用独立的实际数量,不修改被冻结的 `param("n")`。非法 Ali usage 数量记录诊断,并回退有效图片列表数量;缺少有效实际数量时保持发送数量。正常结束
|
||||
结算使用独立的实际数量,不修改被冻结的 `param("n")`。正常结束
|
||||
的 SSE 可以减少数量,客户端提前断开不能减少应收数量。最终消费日志记录 `image_count`。
|
||||
阿里百炼图片模型(万相、Qwen-Image、Z-Image)由 alibaba 任务插件通过 `openai_image` 宿主协议承接:
|
||||
其数量是任务用量事实 `u("image_count")`,提交时按请求数量预留,完成时按上游 `usage.image_count`
|
||||
(Qwen-Image-3.0 为 `usage.output_image_count`)或按各 `choices[].message.content[]` / `results[]`
|
||||
中的图片载荷数结算,任务消费日志同样记录 `image_count`。
|
||||
|
||||
> **注意:** 自动扣除针对 GPT/OpenAI 格式的 API(prompt_tokens 包含子类别)。Claude 格式的 API 不重复扣除缓存;未独立计价的缓存读取加回输入。系统根据上游返回格式自动处理。
|
||||
|
||||
@@ -272,7 +275,10 @@ The usage-log UI treats `request_rules` as the authoritative rule list and rende
|
||||
|
||||
Task plugins can expose validated, provider-specific billing facts through
|
||||
`meta.usageSchema`. Expressions read those facts with `u("key")`. A literal key
|
||||
must be declared by the plugin schema before the expression can be saved.
|
||||
must be declared by the plugin schema before the expression can be saved. A
|
||||
single-plugin model's unchanged stored expression, and its unchanged provider
|
||||
override, stay saveable after the plugin narrows the model's profile; any edit
|
||||
must follow the current profile.
|
||||
|
||||
Multiple plugins may serve the same model with different usage schemas. The
|
||||
executing channel identifies the plugin; clients do not select a billing
|
||||
@@ -323,7 +329,9 @@ pricing and expression-save APIs use the declared target; an ambiguous alias
|
||||
with multiple targets in the same plugin retains the plugin defaults. Request
|
||||
and usage validation use the executing plugin and final upstream model. Polling
|
||||
selects metadata from each saved task's model, including in mixed-model batches.
|
||||
Unmatched models and plugins without profiles retain the plugin defaults.
|
||||
An upstream name that no profile declares, such as a channel mapping to a vendor
|
||||
endpoint ID, falls back to the client-facing model's profile. Unmatched models
|
||||
and plugins without profiles retain the plugin defaults.
|
||||
|
||||
Profiles do not introduce a request-body whitelist or remove legacy multiplier
|
||||
extensions. Numeric hook facts retain a consistent floating-point representation
|
||||
@@ -450,10 +458,12 @@ On save, the expression is validated:
|
||||
When a request arrives and the model uses `tiered_expr` billing:
|
||||
1. Loads expression from `billing_setting.GetBillingExpr()`
|
||||
2. Builds `RequestInput` (headers + body) for `param()` / `header()` functions
|
||||
3. Runs expression with estimated tokens: `RunExprWithRequest(expr, {P, C}, requestInput)`
|
||||
4. Converts output to quota: `rawCost / 1,000,000 * QuotaPerUnit`
|
||||
3. Runs the expression with locally estimated input tokens (`P` and `Len`) and zero output tokens (`C=0`). Client output limits do not affect the reservation. If request token estimation is disabled, the estimated input is zero.
|
||||
4. Converts output to quota: `rawCost / 1,000,000 * QuotaPerUnit`. For token-priced leaves, multiplies this reservation by `quota_setting.pre_consume_multiplier` (default 1; any finite positive decimal). The multiplier does not change context-length tier selection, actual settlement, or request-priced leaves. The snapshot freezes the multiplier for image quantity retries; group retries reuse the multiplied reservation. Older snapshots with no multiplier retain a factor of 1.
|
||||
5. Creates `BillingSnapshot` and stores it on `RelayInfo`. Expression and request state stay frozen for settlement. An auto-group retry refreshes group-dependent fields from the selected group before the next upstream attempt. If a free initial group skipped pre-consume and the retry selects a paid group, the billing session is created before that attempt. If an existing session moves to a more expensive group, its reservation is raised to that group's estimate before sending; cheaper groups are refunded only after actual usage is settled.
|
||||
|
||||
Wallet trust bypass uses `quota_setting.trust_quota_usd` (default 10 USD; 0 disables bypass). Both the wallet and any limited API token must exceed the threshold. The wallet must still cover the estimated reservation before bypass is considered. Subscriptions and asynchronous tasks do not use this bypass. Legacy token-ratio pricing also reserves estimated input cost times the multiplier, without the former `PreConsumedQuota` floor or output-token estimate. Task usage pricing is unchanged.
|
||||
|
||||
### 4. Settlement (Actual Billing)
|
||||
|
||||
**Files**: `service/tiered_settle.go`, `pkg/billingexpr/settle.go`
|
||||
|
||||
@@ -72,6 +72,7 @@ type BillingSnapshot struct {
|
||||
GroupRatio float64 `json:"group_ratio"`
|
||||
EstimatedPromptTokens int `json:"estimated_prompt_tokens"`
|
||||
EstimatedCompletionTokens int `json:"estimated_completion_tokens"`
|
||||
PreConsumeMultiplier float64 `json:"pre_consume_multiplier,omitempty"` // Reservation only; zero in older snapshots means 1.
|
||||
EstimatedQuotaBeforeGroup float64 `json:"estimated_quota_before_group"`
|
||||
EstimatedQuotaAfterGroup int `json:"estimated_quota_after_group"`
|
||||
EstimatedTier string `json:"estimated_tier"`
|
||||
|
||||
+515
-215
@@ -2,16 +2,23 @@ package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/grafana/sobek"
|
||||
"github.com/grafana/sobek/parser"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -46,6 +53,21 @@ func (e *HookError) Unwrap() error {
|
||||
return e.wrapped
|
||||
}
|
||||
|
||||
// ResultError reports a hook result that CallInto could not store: one the
|
||||
// host codec cannot encode (a NaN, an infinity, a cycle) or one the target's
|
||||
// type rejects. Its message is the codec's.
|
||||
type ResultError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *ResultError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *ResultError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func newHookError(hook, rawMessage string, wrapped error) *HookError {
|
||||
var b strings.Builder
|
||||
b.Grow(len(rawMessage))
|
||||
@@ -67,37 +89,6 @@ func newHookError(hook, rawMessage string, wrapped error) *HookError {
|
||||
return &HookError{Hook: hook, Message: message, wrapped: wrapped}
|
||||
}
|
||||
|
||||
func hookErrorFromException(hook string, exc *sobek.Exception, wrapped error) (hookErr *HookError) {
|
||||
// Reading message/toString executes plugin getters, which can throw again
|
||||
// and panic sobek. By this point the caller's recover is already consumed,
|
||||
// so a second panic would crash the process; fall back to a blank message.
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
hookErr = newHookError(hook, "", wrapped)
|
||||
}
|
||||
}()
|
||||
raw := ""
|
||||
if exc != nil {
|
||||
if val := exc.Value(); val != nil && !sobek.IsUndefined(val) && !sobek.IsNull(val) {
|
||||
gotMessage := false
|
||||
if obj, ok := val.(*sobek.Object); ok {
|
||||
if msg := obj.Get("message"); msg != nil && !sobek.IsUndefined(msg) && !sobek.IsNull(msg) {
|
||||
raw = msg.String()
|
||||
gotMessage = true
|
||||
}
|
||||
}
|
||||
if !gotMessage {
|
||||
if exported, ok := val.Export().(string); ok {
|
||||
raw = exported
|
||||
} else {
|
||||
raw = val.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newHookError(hook, raw, wrapped)
|
||||
}
|
||||
|
||||
var forbiddenSyntax = regexp.MustCompile(`(?m)(^|[^A-Za-z0-9_$])(async|await|import)([^A-Za-z0-9_$]|$)`)
|
||||
|
||||
type Options struct {
|
||||
@@ -115,15 +106,36 @@ type Engine struct {
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
log func(string)
|
||||
module *sobek.SourceTextModuleRecord
|
||||
module *moejs.Module
|
||||
pool chan *runtimeInstance
|
||||
semaphore chan struct{}
|
||||
// parked bounds the runtimes that lent their slot to other calls while
|
||||
// utils.fetch waits on the network; its capacity is the concurrency.
|
||||
parked chan struct{}
|
||||
hooksMu sync.RWMutex
|
||||
hooks map[hookKey]moejs.Hook
|
||||
// exports holds the exports that were not undefined once the module
|
||||
// loaded; it is written only by Compile.
|
||||
exports map[string]struct{}
|
||||
}
|
||||
|
||||
// hookKey names a cached hook handle. Contract hooks are an export and at
|
||||
// most two members (protocols.<name>.<hook>); longer paths only come from
|
||||
// fixture requests and are resolved on every call.
|
||||
type hookKey struct {
|
||||
export string
|
||||
members [2]string
|
||||
depth int
|
||||
}
|
||||
|
||||
// Fixture requests name arbitrary members, so the handle cache is bounded.
|
||||
const maxCachedHooks = 256
|
||||
|
||||
type runtimeInstance struct {
|
||||
runtime *sobek.Runtime
|
||||
module sobek.ModuleInstance
|
||||
runtime *moejs.Runtime
|
||||
logContext *runtimeLogContext
|
||||
// call is the hook call running on the runtime, nil between calls.
|
||||
call *runtimeCall
|
||||
}
|
||||
|
||||
type runtimeLogContext struct {
|
||||
@@ -131,23 +143,21 @@ type runtimeLogContext struct {
|
||||
}
|
||||
|
||||
// Compile performs upload-time syntax checks and compiles an ESM plugin once.
|
||||
// All Sobek-specific module and runtime handling is intentionally kept here.
|
||||
// All engine-specific module and runtime handling is intentionally kept here.
|
||||
func Compile(source string, options Options) (*Engine, error) {
|
||||
if match := forbiddenSyntax.FindString(sourceWithoutCommentsAndStrings(source)); match != "" {
|
||||
return nil, fmt.Errorf("unsupported plugin syntax %q: plugins must be synchronous and cannot import modules", strings.TrimSpace(match))
|
||||
}
|
||||
|
||||
resolve := func(_ any, specifier string) (sobek.ModuleRecord, error) {
|
||||
return nil, fmt.Errorf("plugin imports are disabled: %s", specifier)
|
||||
}
|
||||
// Plugin source is untrusted; without this option a sourceMappingURL
|
||||
// comment makes the parser read arbitrary server files via os.ReadFile.
|
||||
module, err := sobek.ParseModule(options.Key+".js", source, resolve, parser.WithDisableSourceMaps)
|
||||
// The compiler never reads files, so a sourceMappingURL comment in
|
||||
// untrusted source stays inert.
|
||||
module, err := moejs.Compile(options.Key+".js", source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile plugin: %w", err)
|
||||
}
|
||||
if err = module.Link(); err != nil {
|
||||
return nil, fmt.Errorf("link plugin: %w", err)
|
||||
// export ... from has no import keyword; the host links no modules.
|
||||
if requests := module.Requests(); len(requests) > 0 {
|
||||
return nil, fmt.Errorf("unsupported plugin syntax: re-export from %q: plugins must be synchronous and cannot import modules", requests[0])
|
||||
}
|
||||
|
||||
timeout := options.Timeout
|
||||
@@ -171,20 +181,28 @@ func Compile(source string, options Options) (*Engine, error) {
|
||||
log: options.Log,
|
||||
module: module,
|
||||
semaphore: make(chan struct{}, concurrency),
|
||||
parked: make(chan struct{}, concurrency),
|
||||
pool: make(chan *runtimeInstance, concurrency),
|
||||
hooks: make(map[hookKey]moejs.Hook),
|
||||
exports: make(map[string]struct{}),
|
||||
}
|
||||
instance, err := engine.newRuntime(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, name := range module.Exports() {
|
||||
if value, found := instance.runtime.Export(name); found && !value.IsUndefined() {
|
||||
engine.exports[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
instance.logContext.context = nil
|
||||
engine.putRuntime(instance)
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// Export returns one module export without exposing Sobek values outside the
|
||||
// Export returns one module export without exposing engine values outside the
|
||||
// engine boundary. It is used for declarative exports such as meta.
|
||||
func (e *Engine) Export(ctx context.Context, exportName string) (result any, err error) {
|
||||
func (e *Engine) Export(ctx context.Context, exportName string) (any, error) {
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
defer func() { <-e.semaphore }()
|
||||
@@ -205,58 +223,49 @@ func (e *Engine) Export(ctx context.Context, exportName string) (result any, err
|
||||
}
|
||||
}()
|
||||
timedOut := errors.New("plugin export timed out")
|
||||
stopInterrupt := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer stopInterrupt()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reusable = false
|
||||
switch value := recovered.(type) {
|
||||
case *sobek.InterruptedError:
|
||||
err = fmt.Errorf("plugin %s@%s export %s interrupted: %v", e.key, e.version, exportName, value.Value())
|
||||
case *sobek.Exception:
|
||||
err = fmt.Errorf("plugin %s@%s export %s failed: %v", e.key, e.version, exportName, value)
|
||||
default:
|
||||
panic(recovered)
|
||||
}
|
||||
}
|
||||
}()
|
||||
value := instance.module.GetBindingValue(exportName)
|
||||
if value == nil || sobek.IsUndefined(value) {
|
||||
watchdog := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer watchdog.stop()
|
||||
value, found := instance.runtime.Export(exportName)
|
||||
if !found || value.IsUndefined() {
|
||||
return nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
return value.Export(), nil
|
||||
result, err := instance.runtime.ToGo(value)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
reusable = false
|
||||
var interrupted *moejs.InterruptedError
|
||||
if errors.As(err, &interrupted) {
|
||||
return nil, fmt.Errorf("plugin %s@%s export %s interrupted: %w", e.key, e.version, exportName, interruptCause(interrupted))
|
||||
}
|
||||
var exception *moejs.Exception
|
||||
errors.As(err, &exception)
|
||||
return nil, fmt.Errorf("plugin %s@%s export %s failed: %w%s", e.key, e.version, exportName, err, thrownAt(instance.runtime, exception))
|
||||
}
|
||||
|
||||
// HasExport reports whether a module export exists. Optional contract hooks
|
||||
// should be detected with this method instead of relying on engine errors.
|
||||
func (e *Engine) HasExport(ctx context.Context, exportName string) (bool, error) {
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
defer func() { <-e.semaphore }()
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
}
|
||||
instance, err := e.getRuntime(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
instance.logContext.context = nil
|
||||
e.putRuntime(instance)
|
||||
}()
|
||||
value := instance.module.GetBindingValue(exportName)
|
||||
return value != nil && !sobek.IsUndefined(value), nil
|
||||
// HasExport reports whether a module export was defined when the plugin
|
||||
// loaded: an export that was undefined then counts as absent, and a later
|
||||
// assignment is not seen. Optional contract hooks should be detected with
|
||||
// this method instead of relying on engine errors. It runs no JavaScript, so
|
||||
// it neither waits for nor takes a call slot.
|
||||
func (e *Engine) HasExport(exportName string) bool {
|
||||
_, found := e.exports[exportName]
|
||||
return found
|
||||
}
|
||||
|
||||
// HasCallablePath reports whether an exported value, or a nested member below
|
||||
// it, exists and is callable.
|
||||
func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members ...string) (found bool, err error) {
|
||||
func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members ...string) (bool, error) {
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
defer func() { <-e.semaphore }()
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
}
|
||||
hook, err := e.hook(exportName, members)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
instance, err := e.getRuntime(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -270,45 +279,72 @@ func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members
|
||||
}
|
||||
}()
|
||||
timedOut := errors.New("plugin inspection timed out")
|
||||
stopInterrupt := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer stopInterrupt()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reusable = false
|
||||
hookName := strings.Join(append([]string{exportName}, members...), ".")
|
||||
switch value := recovered.(type) {
|
||||
case *sobek.InterruptedError:
|
||||
err = fmt.Errorf("plugin %s@%s hook %s inspection interrupted: %v", e.key, e.version, hookName, value.Value())
|
||||
case *sobek.Exception:
|
||||
err = fmt.Errorf("plugin %s@%s hook %s inspection failed: %v", e.key, e.version, hookName, value)
|
||||
default:
|
||||
panic(recovered)
|
||||
}
|
||||
}
|
||||
}()
|
||||
value, _, found := resolveExportPath(instance, exportName, members)
|
||||
if !found {
|
||||
return false, nil
|
||||
watchdog := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer watchdog.stop()
|
||||
found, err := instance.runtime.Has(hook)
|
||||
if err == nil {
|
||||
return found, nil
|
||||
}
|
||||
_, callable := sobek.AssertFunction(value)
|
||||
return callable, nil
|
||||
reusable = false
|
||||
var interrupted *moejs.InterruptedError
|
||||
if errors.As(err, &interrupted) {
|
||||
return false, fmt.Errorf("plugin %s@%s hook %s inspection interrupted: %w", e.key, e.version, hook.Name(), interruptCause(interrupted))
|
||||
}
|
||||
var exception *moejs.Exception
|
||||
errors.As(err, &exception)
|
||||
return false, fmt.Errorf("plugin %s@%s hook %s inspection failed: %w%s", e.key, e.version, hook.Name(), err, thrownAt(instance.runtime, exception))
|
||||
}
|
||||
|
||||
// RawJSON is a hook argument given as JSON text: the hook receives JSON.parse
|
||||
// of it, members in order, whether it is an argument or inside one, with no Go
|
||||
// values built in between; text nested too deep for JSON.parse is decoded with
|
||||
// the host codec instead. The engine parses the bytes in place, without the
|
||||
// copy ParseJSON makes, and strings of the call's result may share them, so
|
||||
// the caller must never change them once passed.
|
||||
type RawJSON []byte
|
||||
|
||||
// Call invokes one named module export and returns its JSON-compatible value.
|
||||
func (e *Engine) Call(ctx context.Context, exportName string, args ...any) (result any, err error) {
|
||||
return e.call(ctx, 0, exportName, nil, args...)
|
||||
result, _, err = e.call(ctx, callOptions{}, exportName, nil, args...)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CallInto invokes one named module export and stores its result in target as
|
||||
// the host codec decodes the encoding of the value Call returns, without that
|
||||
// round trip for plain results. A result the codec cannot encode or target's
|
||||
// type rejects is a *ResultError, and target may then hold part of it.
|
||||
func (e *Engine) CallInto(ctx context.Context, target any, exportName string, args ...any) error {
|
||||
_, _, err := e.call(ctx, callOptions{target: target}, exportName, nil, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallJSONInto is CallInto with the semantics of JSON.stringify's text of the
|
||||
// result instead of the host codec's encoding: objects keep their member
|
||||
// order, which a json.RawMessage in target receives as text, members whose
|
||||
// value is undefined are left out and toJSON runs.
|
||||
func (e *Engine) CallJSONInto(ctx context.Context, target any, exportName string, args ...any) error {
|
||||
_, _, err := e.call(ctx, callOptions{target: target, stringify: true}, exportName, nil, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallMember invokes a function stored on an exported object, such as a
|
||||
// renderer in the renderers export.
|
||||
func (e *Engine) CallMember(ctx context.Context, exportName, memberName string, args ...any) (result any, err error) {
|
||||
return e.call(ctx, 0, exportName, []string{memberName}, args...)
|
||||
result, _, err = e.call(ctx, callOptions{}, exportName, []string{memberName}, args...)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CallPath invokes a function nested below an exported object. It is used for
|
||||
// protocol hooks such as protocols.openai_responses.renderEvents.
|
||||
func (e *Engine) CallPath(ctx context.Context, exportName string, members []string, args ...any) (result any, err error) {
|
||||
return e.call(ctx, 0, exportName, members, args...)
|
||||
result, _, err = e.call(ctx, callOptions{}, exportName, members, args...)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CallPathInto is CallInto for a function nested below an exported object.
|
||||
func (e *Engine) CallPathInto(ctx context.Context, target any, exportName string, members []string, args ...any) error {
|
||||
_, _, err := e.call(ctx, callOptions{target: target}, exportName, members, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallPathWithAdmissionTimeout gives long-lived observers a separate bound for
|
||||
@@ -322,89 +358,191 @@ func (e *Engine) CallPathWithAdmissionTimeout(
|
||||
members []string,
|
||||
args ...any,
|
||||
) (result any, err error) {
|
||||
return e.call(ctx, admissionTimeout, exportName, members, args...)
|
||||
result, _, err = e.call(ctx, callOptions{admissionTimeout: admissionTimeout}, exportName, members, args...)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (e *Engine) call(
|
||||
// CallPathWithMemberJSON is CallPathWithAdmissionTimeout that also returns the
|
||||
// result's member as the text JSON.stringify writes for it, which keeps the
|
||||
// member order of its objects. The text is nil when member is empty or the
|
||||
// result has no such member.
|
||||
func (e *Engine) CallPathWithMemberJSON(
|
||||
ctx context.Context,
|
||||
admissionTimeout time.Duration,
|
||||
member string,
|
||||
exportName string,
|
||||
members []string,
|
||||
args ...any,
|
||||
) (result any, err error) {
|
||||
if err = e.acquireCallSlot(ctx, admissionTimeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { <-e.semaphore }()
|
||||
) (result any, text json.RawMessage, err error) {
|
||||
return e.call(ctx, callOptions{admissionTimeout: admissionTimeout, jsonMember: member}, exportName, members, args...)
|
||||
}
|
||||
|
||||
// callOptions is what a hook call does besides calling: the bound on waiting
|
||||
// for a JavaScript slot, the Go value the result is stored in instead of
|
||||
// being returned (from its JSON.stringify text when stringify is set), and a
|
||||
// member of the result also returned as JSON text.
|
||||
type callOptions struct {
|
||||
admissionTimeout time.Duration
|
||||
target any
|
||||
stringify bool
|
||||
jsonMember string
|
||||
}
|
||||
|
||||
func (e *Engine) call(ctx context.Context, opts callOptions, exportName string, members []string, args ...any) (any, json.RawMessage, error) {
|
||||
if err := e.acquireCallSlot(ctx, opts.admissionTimeout); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
call := &runtimeCall{engine: e, ctx: ctx, holdsSlot: true}
|
||||
// utils.fetch may lend the slot out; return what the call holds now.
|
||||
defer call.release()
|
||||
|
||||
hook, err := e.hook(exportName, members)
|
||||
if err != nil {
|
||||
if len(members) == 0 {
|
||||
return nil, nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("plugin hook %q not found", exportName)
|
||||
}
|
||||
instance, err := e.getRuntime(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
call.runtime, call.hook = instance.runtime, hook.Name()
|
||||
call.fetcher, _ = ctx.Value(fetcherContextKey{}).(Fetcher)
|
||||
instance.call = call
|
||||
reusable := true
|
||||
defer func() {
|
||||
instance.runtime.ClearInterrupt()
|
||||
instance.logContext.context = nil
|
||||
instance.call = nil
|
||||
if reusable {
|
||||
// An idle pooled runtime must not keep this call's request
|
||||
// data alive.
|
||||
instance.runtime.ReleaseCallData()
|
||||
e.putRuntime(instance)
|
||||
}
|
||||
}()
|
||||
|
||||
hookName := strings.Join(append([]string{exportName}, members...), ".")
|
||||
timedOut := errors.New("plugin call timed out")
|
||||
stopInterrupt := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer stopInterrupt()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reusable = false
|
||||
switch value := recovered.(type) {
|
||||
case *sobek.InterruptedError:
|
||||
err = fmt.Errorf("plugin %s@%s hook %s interrupted: %v", e.key, e.version, hookName, value.Value())
|
||||
case *sobek.Exception:
|
||||
wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %v", e.key, e.version, hookName, value)
|
||||
err = hookErrorFromException(hookName, value, wrapped)
|
||||
default:
|
||||
panic(recovered)
|
||||
callArgs := make([]moejs.Value, 0, 4)
|
||||
for index, arg := range args {
|
||||
var converted moejs.Value
|
||||
if raw, ok := arg.(RawJSON); !ok {
|
||||
value, _ := pluginValue(instance.runtime, arg, 0)
|
||||
converted, err = instance.runtime.FromGo(value)
|
||||
} else if converted, err = instance.runtime.ParseJSONString(unsafe.String(unsafe.SliceData(raw), len(raw))); err != nil {
|
||||
// JSON.parse stops at a lower nesting depth than the host codec.
|
||||
var decoded any
|
||||
if err = common.Unmarshal(raw, &decoded); err == nil {
|
||||
converted, err = instance.runtime.FromGo(decoded)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
value, resolvedHookName, found := resolveExportPath(instance, exportName, members)
|
||||
hookName = resolvedHookName
|
||||
if !found {
|
||||
if len(members) == 0 {
|
||||
return nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("plugin %s@%s hook %s argument %d: %w", e.key, e.version, hook.Name(), index+1, err)
|
||||
}
|
||||
return nil, fmt.Errorf("plugin hook %q not found", hookName)
|
||||
}
|
||||
if value == nil || sobek.IsUndefined(value) {
|
||||
return nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
callable, ok := sobek.AssertFunction(value)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin hook %q is not a function", hookName)
|
||||
callArgs = append(callArgs, converted)
|
||||
}
|
||||
|
||||
callArgs := make([]sobek.Value, len(args))
|
||||
for i, arg := range args {
|
||||
callArgs[i] = instance.runtime.ToValue(arg)
|
||||
}
|
||||
timedOut := errors.New("plugin call timed out")
|
||||
call.watchdog = watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
// utils.fetch pauses and restarts the watchdog; stop whichever watch is
|
||||
// current when the call ends.
|
||||
defer call.watchdog.stop()
|
||||
|
||||
value, err = callable(sobek.Undefined(), callArgs...)
|
||||
if err != nil {
|
||||
var interrupted *sobek.InterruptedError
|
||||
value, err := instance.runtime.Call(hook, callArgs...)
|
||||
var interrupted *moejs.InterruptedError
|
||||
var exception *moejs.Exception
|
||||
if err == nil {
|
||||
var result any
|
||||
var memberText json.RawMessage
|
||||
switch {
|
||||
case opts.target != nil && opts.stringify:
|
||||
err = instance.runtime.Unmarshal(value, opts.target)
|
||||
case opts.target != nil:
|
||||
err = instance.runtime.ToGoInto(value, opts.target)
|
||||
default:
|
||||
result, err = instance.runtime.ToGo(value)
|
||||
}
|
||||
if err == nil && opts.jsonMember != "" {
|
||||
// After the result's Go value: AppendJSON may run a toJSON or
|
||||
// a getter, which ToGo never does.
|
||||
var member moejs.Value
|
||||
if member, err = instance.runtime.Get(value, opts.jsonMember); err == nil && !member.IsUndefined() {
|
||||
memberText, err = instance.runtime.AppendJSON(nil, member)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
return result, memberText, nil
|
||||
}
|
||||
if errors.As(err, &interrupted) {
|
||||
reusable = false
|
||||
return nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hookName, err)
|
||||
return nil, nil, fmt.Errorf("plugin %s@%s hook %s interrupted: %w", e.key, e.version, hook.Name(), interruptCause(interrupted))
|
||||
}
|
||||
wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hookName, err)
|
||||
var exc *sobek.Exception
|
||||
if errors.As(err, &exc) {
|
||||
return nil, hookErrorFromException(hookName, exc, wrapped)
|
||||
// Past ToGo's errors, ToGoInto fails as json.Marshal and
|
||||
// json.Unmarshal do, which is the host codec (common/json.go), and
|
||||
// Unmarshal and AppendJSON as JSON.stringify does (a BigInt, a cycle).
|
||||
var internal *moejs.InternalError
|
||||
if (opts.target != nil || opts.jsonMember != "") && !errors.As(err, &exception) && !errors.As(err, &internal) {
|
||||
return nil, nil, &ResultError{err: err}
|
||||
}
|
||||
return nil, wrapped
|
||||
}
|
||||
return value.Export(), nil
|
||||
switch {
|
||||
case errors.Is(err, moejs.ErrHookNotFound):
|
||||
if len(members) == 0 {
|
||||
return nil, nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
// Name the path up to the first missing member, as far as data
|
||||
// properties show it; getters on the path are not run again.
|
||||
hookName := exportName
|
||||
current, found := instance.runtime.Export(exportName)
|
||||
for _, member := range members {
|
||||
if !found || current.IsNullish() {
|
||||
break
|
||||
}
|
||||
hookName += "." + member
|
||||
if !current.IsObject() {
|
||||
break
|
||||
}
|
||||
current, found = current.AsObject().GetOwnDataValue(instance.runtime.Realm().KeyFromGoString(member))
|
||||
}
|
||||
return nil, nil, fmt.Errorf("plugin hook %q not found", hookName)
|
||||
case errors.Is(err, moejs.ErrNotCallable):
|
||||
return nil, nil, fmt.Errorf("plugin hook %q is not a function", hook.Name())
|
||||
case errors.As(err, &interrupted):
|
||||
reusable = false
|
||||
return nil, nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hook.Name(), interruptCause(interrupted))
|
||||
case errors.As(err, &exception):
|
||||
wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %w%s", e.key, e.version, hook.Name(), err, thrownAt(instance.runtime, exception))
|
||||
return nil, nil, newHookError(hook.Name(), exception.Message(), wrapped)
|
||||
}
|
||||
reusable = false
|
||||
return nil, nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hook.Name(), err)
|
||||
}
|
||||
|
||||
// hook resolves a hook path against the module's export table. Only
|
||||
// declared exports resolve; the handle is cached, while the bindings it
|
||||
// names are read live on every call.
|
||||
func (e *Engine) hook(exportName string, members []string) (moejs.Hook, error) {
|
||||
key := hookKey{export: exportName, depth: len(members)}
|
||||
cacheable := len(members) <= len(key.members)
|
||||
if cacheable {
|
||||
copy(key.members[:], members)
|
||||
e.hooksMu.RLock()
|
||||
hook, found := e.hooks[key]
|
||||
e.hooksMu.RUnlock()
|
||||
if found {
|
||||
return hook, nil
|
||||
}
|
||||
}
|
||||
hook, err := e.module.Hook(exportName, members...)
|
||||
if err != nil || !cacheable {
|
||||
return hook, err
|
||||
}
|
||||
e.hooksMu.Lock()
|
||||
if len(e.hooks) < maxCachedHooks {
|
||||
e.hooks[key] = hook
|
||||
}
|
||||
e.hooksMu.Unlock()
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
func (e *Engine) acquireCallSlot(ctx context.Context, admissionTimeout time.Duration) error {
|
||||
@@ -432,25 +570,146 @@ func (e *Engine) acquireCallSlot(ctx context.Context, admissionTimeout time.Dura
|
||||
}
|
||||
}
|
||||
|
||||
func resolveExportPath(instance *runtimeInstance, exportName string, members []string) (sobek.Value, string, bool) {
|
||||
value := instance.module.GetBindingValue(exportName)
|
||||
hookName := exportName
|
||||
if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) {
|
||||
return nil, hookName, false
|
||||
// interruptCause is what stopped a runtime: the context cause
|
||||
// watchRuntimeContext interrupted it with, so errors.Is sees a caller's
|
||||
// cancellation through the host error.
|
||||
func interruptCause(interrupted *moejs.InterruptedError) error {
|
||||
if cause, ok := interrupted.Value.(error); ok {
|
||||
return cause
|
||||
}
|
||||
for _, member := range members {
|
||||
hookName += "." + member
|
||||
object := value.ToObject(instance.runtime)
|
||||
own := slices.Contains(object.GetOwnPropertyNames(), member)
|
||||
if !own {
|
||||
return nil, hookName, false
|
||||
return interrupted
|
||||
}
|
||||
|
||||
// thrownAt is the frame an Error was thrown from, as " at fn (file:line:col)",
|
||||
// for host logs; it is empty for thrown values that are not Errors and for
|
||||
// errors that are not exceptions (exception is nil).
|
||||
func thrownAt(runtime *moejs.Runtime, exception *moejs.Exception) string {
|
||||
_, frames, found := strings.Cut(runtime.StackTrace(exception), "\n at ")
|
||||
if !found {
|
||||
return ""
|
||||
}
|
||||
frame, _, _ := strings.Cut(frames, "\n")
|
||||
return " at " + frame
|
||||
}
|
||||
|
||||
// maxPluginValueDepth bounds the argument walk; deeper values, which no
|
||||
// host-built argument reaches, are passed on as they are.
|
||||
const maxPluginValueDepth = 512
|
||||
|
||||
// pluginValueContainers are the container types moejs converts; named types
|
||||
// with one of these underlying types are only retyped.
|
||||
var pluginValueContainers = []reflect.Type{
|
||||
reflect.TypeFor[map[string]any](),
|
||||
reflect.TypeFor[map[string]string](),
|
||||
reflect.TypeFor[map[string][]string](),
|
||||
reflect.TypeFor[[]any](),
|
||||
reflect.TypeFor[[]string](),
|
||||
reflect.TypeFor[[]map[string]any](),
|
||||
}
|
||||
|
||||
// pluginValue returns a hook argument in the shapes moejs converts: nil,
|
||||
// booleans, numbers, strings, JSON-shaped maps and slices and engine values.
|
||||
// A RawJSON inside the argument becomes rt's parse of it (RawJSON); other Go
|
||||
// values (structs, pointers, maps and slices of other element types) take
|
||||
// their JSON form through the configured codec, and named scalars and
|
||||
// containers become their underlying type. changed is false when v has
|
||||
// supported shapes throughout, the usual case, so an argument is passed on
|
||||
// without a copy.
|
||||
func pluginValue(rt *moejs.Runtime, v any, depth int) (converted any, changed bool) {
|
||||
if depth > maxPluginValueDepth {
|
||||
return v, false
|
||||
}
|
||||
switch typed := v.(type) {
|
||||
case nil, bool, string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64,
|
||||
float32, float64, json.Number, *big.Int, []byte, []string, map[string]string, map[string][]string,
|
||||
moejs.Value, moejs.NativeFunc:
|
||||
return v, false
|
||||
case RawJSON:
|
||||
if parsed, err := rt.ParseJSONString(unsafe.String(unsafe.SliceData(typed), len(typed))); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
value = object.Get(member)
|
||||
if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) {
|
||||
return nil, hookName, false
|
||||
// JSON.parse stops at a lower nesting depth than the host codec.
|
||||
var decoded any
|
||||
if err := common.Unmarshal(typed, &decoded); err != nil {
|
||||
return v, false
|
||||
}
|
||||
return decoded, true
|
||||
case map[string]any:
|
||||
var copied map[string]any
|
||||
for key, item := range typed {
|
||||
if item, changed = pluginValue(rt, item, depth+1); changed {
|
||||
if copied == nil {
|
||||
copied = maps.Clone(typed)
|
||||
}
|
||||
copied[key] = item
|
||||
}
|
||||
}
|
||||
if copied == nil {
|
||||
return v, false
|
||||
}
|
||||
return copied, true
|
||||
case []any:
|
||||
var copied []any
|
||||
for index, item := range typed {
|
||||
if item, changed = pluginValue(rt, item, depth+1); changed {
|
||||
if copied == nil {
|
||||
copied = slices.Clone(typed)
|
||||
}
|
||||
copied[index] = item
|
||||
}
|
||||
}
|
||||
if copied == nil {
|
||||
return v, false
|
||||
}
|
||||
return copied, true
|
||||
case []map[string]any:
|
||||
var copied []map[string]any
|
||||
for index, item := range typed {
|
||||
if next, changed := pluginValue(rt, item, depth+1); changed {
|
||||
if copied == nil {
|
||||
copied = slices.Clone(typed)
|
||||
}
|
||||
copied[index] = next.(map[string]any)
|
||||
}
|
||||
}
|
||||
if copied == nil {
|
||||
return v, false
|
||||
}
|
||||
return copied, true
|
||||
}
|
||||
_, marshaler := v.(json.Marshaler)
|
||||
_, textMarshaler := v.(encoding.TextMarshaler)
|
||||
if !marshaler && !textMarshaler {
|
||||
value := reflect.ValueOf(v)
|
||||
switch value.Kind() {
|
||||
case reflect.Bool:
|
||||
return value.Bool(), true
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return value.Int(), true
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return value.Uint(), true
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return value.Float(), true
|
||||
case reflect.String:
|
||||
return value.String(), true
|
||||
case reflect.Map, reflect.Slice:
|
||||
for _, container := range pluginValueContainers {
|
||||
if value.Type().ConvertibleTo(container) {
|
||||
converted, _ = pluginValue(rt, value.Convert(container).Interface(), depth)
|
||||
return converted, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return value, hookName, true
|
||||
data, err := common.Marshal(v)
|
||||
if err != nil {
|
||||
return v, false
|
||||
}
|
||||
var decoded any
|
||||
if err = common.Unmarshal(data, &decoded); err != nil {
|
||||
return v, false
|
||||
}
|
||||
return decoded, true
|
||||
}
|
||||
|
||||
// Idle runtimes are bounded by the execution limit and survive GC. Start with
|
||||
@@ -472,26 +731,81 @@ func (e *Engine) getRuntime(ctx context.Context) (*runtimeInstance, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// A timeout callback must finish before its runtime can be reused. Merely
|
||||
// stopping a timer does not wait for an already-started Interrupt call.
|
||||
func watchRuntimeContext(runtime *sobek.Runtime, ctx context.Context, timeout time.Duration, timeoutError error) func() {
|
||||
callContext, cancel := context.WithTimeoutCause(ctx, timeout, timeoutError)
|
||||
// runtimeWatchdog interrupts a runtime with timeoutError once it has run for
|
||||
// its timeout, or with the context's cause once ctx ends. utils.fetch pauses
|
||||
// it, so the timeout counts JavaScript time only. A timeout callback must
|
||||
// finish before its runtime can be reused: merely stopping a timer does not
|
||||
// wait for an already-started Interrupt call.
|
||||
type runtimeWatchdog struct {
|
||||
runtime *moejs.Runtime
|
||||
ctx context.Context
|
||||
timeoutError error
|
||||
remaining time.Duration
|
||||
started time.Time
|
||||
watch context.Context
|
||||
stopWatch func() bool
|
||||
cancel context.CancelFunc
|
||||
interrupted chan struct{}
|
||||
paused bool
|
||||
}
|
||||
|
||||
func watchRuntimeContext(runtime *moejs.Runtime, ctx context.Context, timeout time.Duration, timeoutError error) *runtimeWatchdog {
|
||||
watchdog := &runtimeWatchdog{runtime: runtime, ctx: ctx, timeoutError: timeoutError, remaining: timeout}
|
||||
watchdog.start()
|
||||
return watchdog
|
||||
}
|
||||
|
||||
func (w *runtimeWatchdog) start() {
|
||||
watch, cancel := context.WithTimeoutCause(w.ctx, w.remaining, w.timeoutError)
|
||||
interrupted := make(chan struct{})
|
||||
stop := context.AfterFunc(callContext, func() {
|
||||
runtime.Interrupt(context.Cause(callContext))
|
||||
w.stopWatch = context.AfterFunc(watch, func() {
|
||||
w.runtime.Interrupt(context.Cause(watch))
|
||||
close(interrupted)
|
||||
})
|
||||
return func() {
|
||||
if !stop() {
|
||||
<-interrupted
|
||||
}
|
||||
cancel()
|
||||
w.watch, w.cancel, w.interrupted = watch, cancel, interrupted
|
||||
w.started, w.paused = time.Now(), false
|
||||
}
|
||||
|
||||
func (w *runtimeWatchdog) stop() {
|
||||
if w.paused {
|
||||
return
|
||||
}
|
||||
if !w.stopWatch() {
|
||||
<-w.interrupted
|
||||
}
|
||||
w.cancel()
|
||||
}
|
||||
|
||||
// pause stops the clock while a host function waits. It reports false when
|
||||
// the watchdog already fired: its interrupt is then ending the call, and the
|
||||
// host function must return instead of waiting.
|
||||
func (w *runtimeWatchdog) pause() bool {
|
||||
if !w.stopWatch() {
|
||||
<-w.interrupted
|
||||
return false
|
||||
}
|
||||
w.cancel()
|
||||
w.remaining -= time.Since(w.started)
|
||||
w.paused = true
|
||||
return true
|
||||
}
|
||||
|
||||
// resume restarts the clock with the time left at pause and the same timeout
|
||||
// cause. When the time is up or ctx ended meanwhile, it waits for the
|
||||
// interrupt that follows, so the host function can return it.
|
||||
func (w *runtimeWatchdog) resume() {
|
||||
w.start()
|
||||
if w.watch.Err() != nil {
|
||||
<-w.interrupted
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) newRuntime(ctx context.Context) (instance *runtimeInstance, err error) {
|
||||
runtime := sobek.New()
|
||||
func (e *Engine) newRuntime(ctx context.Context) (*runtimeInstance, error) {
|
||||
// Code compiled from strings would bypass the upload-time syntax checks,
|
||||
// so eval and the Function constructors throw an EvalError.
|
||||
runtime := moejs.NewRuntime(moejs.Options{DisableDynamicCode: true})
|
||||
logContext := &runtimeLogContext{context: ctx}
|
||||
instance := &runtimeInstance{runtime: runtime, logContext: logContext}
|
||||
logOutput := e.log
|
||||
if logOutput == nil {
|
||||
logOutput = func(message string) {
|
||||
@@ -500,36 +814,22 @@ func (e *Engine) newRuntime(ctx context.Context) (instance *runtimeInstance, err
|
||||
}
|
||||
if err := injectGlobals(runtime, func() string {
|
||||
return fmt.Sprintf("[plugin:%s@%s]", e.key, e.version)
|
||||
}, e.now, logOutput); err != nil {
|
||||
}, e.now, logOutput, func() *runtimeCall { return instance.call }); err != nil {
|
||||
return nil, fmt.Errorf("inject plugin utils: %w", err)
|
||||
}
|
||||
timedOut := errors.New("plugin initialization timed out")
|
||||
stopInterrupt := watchRuntimeContext(runtime, ctx, e.timeout, timedOut)
|
||||
defer func() {
|
||||
stopInterrupt()
|
||||
runtime.ClearInterrupt()
|
||||
}()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
if interrupted, ok := recovered.(*sobek.InterruptedError); ok {
|
||||
instance = nil
|
||||
err = fmt.Errorf("initialize plugin %s@%s: %v", e.key, e.version, interrupted.Value())
|
||||
return
|
||||
}
|
||||
panic(recovered)
|
||||
}
|
||||
}()
|
||||
promise := runtime.CyclicModuleRecordEvaluate(e.module, func(_ any, specifier string) (sobek.ModuleRecord, error) {
|
||||
return nil, fmt.Errorf("plugin imports are disabled: %s", specifier)
|
||||
})
|
||||
if promise.State() != sobek.PromiseStateFulfilled {
|
||||
return nil, fmt.Errorf("evaluate plugin: %v", promise.Result().Export())
|
||||
watchdog := watchRuntimeContext(runtime, ctx, e.timeout, timedOut)
|
||||
err := runtime.Load(e.module)
|
||||
watchdog.stop()
|
||||
runtime.ClearInterrupt()
|
||||
var interrupted *moejs.InterruptedError
|
||||
if errors.As(err, &interrupted) {
|
||||
return nil, fmt.Errorf("initialize plugin %s@%s: %w", e.key, e.version, interruptCause(interrupted))
|
||||
}
|
||||
return &runtimeInstance{
|
||||
runtime: runtime,
|
||||
module: runtime.GetModuleInstance(e.module),
|
||||
logContext: logContext,
|
||||
}, nil
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluate plugin: %w", err)
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func sourceWithoutCommentsAndStrings(source string) string {
|
||||
|
||||
@@ -3,9 +3,12 @@ package jsplugin
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
@@ -14,6 +17,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
"weak"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
@@ -107,6 +112,72 @@ export function invalid(kind) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineRawJSONArgumentsMatchDecodedArguments(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export function describe(...values) {
|
||||
const seen = [];
|
||||
const stack = values.reverse().map((v) => ({v}));
|
||||
while (stack.length > 0) {
|
||||
const {v, key, close} = stack.pop();
|
||||
if (close) {
|
||||
seen.push(close);
|
||||
continue;
|
||||
}
|
||||
if (key !== undefined) seen.push(key);
|
||||
if (v === null || typeof v !== "object") {
|
||||
seen.push(typeof v + ":" + (Object.is(v, -0) ? "-0" : JSON.stringify(v)));
|
||||
continue;
|
||||
}
|
||||
const array = Array.isArray(v);
|
||||
seen.push(array ? "[" : Object.getPrototypeOf(v) === Object.prototype ? "{" : "{?");
|
||||
stack.push({close: array ? "]" : "}"});
|
||||
const keys = Object.keys(v);
|
||||
for (let i = keys.length - 1; i >= 0; i--) {
|
||||
stack.push({v: v[keys[i]], key: array ? undefined : JSON.stringify(keys[i])});
|
||||
}
|
||||
}
|
||||
return seen.join(" ");
|
||||
}`, Options{})
|
||||
require.NoError(t, err)
|
||||
// Hosts pass text written by common.Marshal, where both paths agree on
|
||||
// key order, numbers and strings.
|
||||
values := map[string]any{
|
||||
"object": map[string]any{
|
||||
"__proto__": map[string]any{"polluted": true},
|
||||
"10": "ten", "2": "two", "a": []any{}, "b": map[string]any{},
|
||||
"numbers": []any{0, math.Copysign(0, -1), 1e21, int64(123456789012345678), 1.5e-7, -12, math.MaxInt64, math.SmallestNonzeroFloat64},
|
||||
"strings": []any{"", "<>&
", "é中😀", "\u0000\t\"\\", "a\xffb"},
|
||||
"nested": map[string]any{"z": nil, "y": []any{true, false, map[string]any{"x": []any{[]any{1}}}}},
|
||||
},
|
||||
"array": []any{map[string]any{"id": "x", "b": 1, "a": 2}, nil, "s"},
|
||||
"string": "plain",
|
||||
"number": 42.5,
|
||||
"null": nil,
|
||||
"deep": json.RawMessage(strings.Repeat("[", 600) + strings.Repeat("]", 600)),
|
||||
}
|
||||
for name, value := range values {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
text, err := common.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
var decoded any
|
||||
require.NoError(t, common.Unmarshal(text, &decoded))
|
||||
want, err := engine.Call(t.Context(), "describe", decoded)
|
||||
require.NoError(t, err)
|
||||
got, err := engine.Call(t.Context(), "describe", RawJSON(text))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
nested, err := engine.Call(t.Context(), "describe", map[string]any{"value": RawJSON(text)})
|
||||
require.NoError(t, err)
|
||||
wantNested, err := engine.Call(t.Context(), "describe", map[string]any{"value": decoded})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantNested, nested)
|
||||
})
|
||||
}
|
||||
|
||||
_, err = engine.Call(t.Context(), "describe", "first", RawJSON(`{"a":`))
|
||||
require.EqualError(t, err, "plugin @ hook describe argument 2: unexpected end of JSON input")
|
||||
}
|
||||
|
||||
func TestJSONStateChangesAndEncodedLimits(t *testing.T) {
|
||||
const changes = `[
|
||||
[{"op":"set","path":[],"value":{"text":"<","parts":[],"nested":{"value":null}}}],
|
||||
@@ -388,6 +459,8 @@ func TestCompileRejectsAsynchronousAndImportedPlugins(t *testing.T) {
|
||||
"static import": `import value from "dependency"; export function run() { return value; }`,
|
||||
"dynamic import": `export function run() { return import("dependency"); }`,
|
||||
"top-level await": `const value = await work(); export function run() { return value; }`,
|
||||
"export from": `export { value as run } from "dependency";`,
|
||||
"export star": `export * from "dependency"; export function run() {}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Compile(source, Options{Key: "invalid"})
|
||||
@@ -398,6 +471,18 @@ func TestCompileRejectsAsynchronousAndImportedPlugins(t *testing.T) {
|
||||
|
||||
_, err := Compile(`export function run() { return "import async await"; }`, Options{Key: "valid"})
|
||||
require.NoError(t, err)
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"eval": `return eval("import('dependency')");`,
|
||||
"Function constructor": `return Function("return async function () {}")();`,
|
||||
} {
|
||||
engine, err := Compile(`export function run() { `+body+` }`, Options{Key: "dynamic"})
|
||||
require.NoError(t, err, name)
|
||||
_, err = engine.Call(context.Background(), "run")
|
||||
assert.ErrorContains(t, err, "EvalError", name)
|
||||
}
|
||||
_, err = Compile(`eval("1"); export function run() {}`, Options{Key: "dynamic"})
|
||||
assert.ErrorContains(t, err, "EvalError")
|
||||
}
|
||||
|
||||
func TestCompileIgnoresSourceMapDirectives(t *testing.T) {
|
||||
@@ -445,6 +530,22 @@ func TestEngineDoesNotReinitializeIdleRuntimeAfterGC(t *testing.T) {
|
||||
assert.Equal(t, int64(1), initialized.Load())
|
||||
}
|
||||
|
||||
func TestEngineIdleRuntimeReleasesLastCallArguments(t *testing.T) {
|
||||
engine, err := Compile(`export function run(body){return body.image.length;}`, Options{Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
image := strings.Repeat("A", 1<<20)
|
||||
released := weak.Make(unsafe.StringData(image))
|
||||
value, err := engine.Call(t.Context(), "run", map[string]any{"image": image})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1<<20), value)
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
assert.Nil(t, released.Value(), "an idle pooled runtime must not keep the last request body alive")
|
||||
value, err = engine.Call(t.Context(), "run", map[string]any{"image": "AB"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), value)
|
||||
}
|
||||
|
||||
func TestEngineBoundsConcurrentCallsAndCancelsAnOccupiedRuntime(t *testing.T) {
|
||||
entered := make(chan struct{}, 2)
|
||||
release := make(chan struct{})
|
||||
@@ -475,6 +576,193 @@ func TestEngineBoundsConcurrentCallsAndCancelsAnOccupiedRuntime(t *testing.T) {
|
||||
require.NoError(t, <-healthy)
|
||||
}
|
||||
|
||||
func TestEngineFetchContract(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export function run(request) { return utils.fetch(request); }
|
||||
export function fetchBytes(url) { return Array.from(new Uint8Array(utils.fetch({url: url, responseType: "bytes"}).body)); }
|
||||
export function fetchFiveTimes() {
|
||||
const failures = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try { utils.fetch({url: "https://api.example/json"}); } catch (e) { failures.push(e.message); }
|
||||
}
|
||||
return failures;
|
||||
}`, Options{Key: "fetch"})
|
||||
require.NoError(t, err)
|
||||
var requests []FetchRequest
|
||||
ctx := WithFetcher(t.Context(), func(_ context.Context, request FetchRequest) (FetchResponse, error) {
|
||||
requests = append(requests, request)
|
||||
switch request.URL {
|
||||
case "https://api.example/json":
|
||||
return FetchResponse{Status: http.StatusOK, Headers: map[string]string{"Content-Type": "application/json"}, Body: []byte(`{"file":{"id":7}}`)}, nil
|
||||
case "https://api.example/text":
|
||||
return FetchResponse{Status: http.StatusNotFound, Body: []byte("not found")}, nil
|
||||
case "https://api.example/bytes":
|
||||
return FetchResponse{Status: http.StatusPartialContent, Body: []byte{0, 0x80, 0xff, '{'}}, nil
|
||||
case "https://api.example/large":
|
||||
return FetchResponse{Status: http.StatusOK, Body: bytes.Repeat([]byte("a"), MaxFetchBodyBytes+1)}, nil
|
||||
case "https://api.example/down?token=secret":
|
||||
return FetchResponse{}, &url.Error{Op: "Get", URL: request.URL, Err: errors.New("connection refused")}
|
||||
}
|
||||
return FetchResponse{Status: http.StatusOK, Body: []byte(`{"ignored":true}`)}, nil
|
||||
})
|
||||
|
||||
_, err = engine.Call(t.Context(), "run", map[string]any{"url": "https://api.example/json"})
|
||||
assert.ErrorContains(t, err, "utils.fetch is not available in run")
|
||||
|
||||
value, err := engine.Call(ctx, "run", map[string]any{"url": "https://api.example/json", "headers": map[string]any{"Authorization": "Bearer key"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"status": int64(http.StatusOK),
|
||||
"headers": map[string]any{"Content-Type": "application/json"},
|
||||
"body": map[string]any{"file": map[string]any{"id": int64(7)}},
|
||||
}, value)
|
||||
assert.Equal(t, []FetchRequest{{Hook: "run", URL: "https://api.example/json", Method: http.MethodGet, Headers: map[string]string{"Authorization": "Bearer key"}}}, requests)
|
||||
|
||||
value, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/text"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"status": int64(http.StatusNotFound), "headers": map[string]any{}, "body": "not found"}, value)
|
||||
|
||||
value, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/head", "method": "head"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", value.(map[string]any)["body"])
|
||||
assert.Equal(t, http.MethodHead, requests[len(requests)-1].Method)
|
||||
|
||||
// Bytes that are neither JSON nor UTF-8 reach the plugin unchanged.
|
||||
value, err = engine.Call(ctx, "fetchBytes", "https://api.example/bytes")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []any{int64(0), int64(0x80), int64(0xff), int64('{')}, value)
|
||||
|
||||
sent := len(requests)
|
||||
for name, request := range map[string]map[string]any{
|
||||
"unsupported member \"body\"": {"url": "https://api.example/json", "body": "x"},
|
||||
"only GET and HEAD": {"url": "https://api.example/json", "method": "POST"},
|
||||
`responseType must be "json" or "bytes"`: {"url": "https://api.example/json", "responseType": "text"},
|
||||
"request header \"Host\" is not": {"url": "https://api.example/json?token=secret", "headers": map[string]any{"Host": "other.example"}},
|
||||
"absolute http(s) URL": {"url": "/v1/files/retrieve"},
|
||||
"exceeds 65536 bytes": {"url": "https://api.example/" + strings.Repeat("a", 64<<10)},
|
||||
} {
|
||||
_, err = engine.Call(ctx, "run", request)
|
||||
require.Error(t, err, name)
|
||||
assert.Contains(t, err.Error(), name)
|
||||
assert.NotContains(t, err.Error(), "secret")
|
||||
}
|
||||
assert.Len(t, requests, sent, "rejected requests must not reach the fetcher")
|
||||
|
||||
_, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/large"})
|
||||
assert.ErrorContains(t, err, "utils.fetch response from api.example exceeds 1048576 bytes")
|
||||
|
||||
_, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/down?token=secret"})
|
||||
var hookErr *HookError
|
||||
require.ErrorAs(t, err, &hookErr)
|
||||
assert.Equal(t, "utils.fetch to api.example failed: connection refused", hookErr.Message)
|
||||
|
||||
value, err = engine.Call(ctx, "fetchFiveTimes")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []any{"utils.fetch allows at most 4 requests per hook call"}, value)
|
||||
}
|
||||
|
||||
func TestEngineFetchLendsItsExecutionSlot(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export function fetchOnce() { return utils.fetch({url: "https://api.example/slow"}).body; }
|
||||
export function plain() { return 42; }`, Options{Key: "fetch-slot", Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
ctx := WithFetcher(t.Context(), func(context.Context, FetchRequest) (FetchResponse, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return FetchResponse{Status: http.StatusOK, Body: []byte(`"done"`)}, nil
|
||||
})
|
||||
fetched := make(chan any, 1)
|
||||
go func() {
|
||||
value, err := engine.Call(ctx, "fetchOnce")
|
||||
assert.NoError(t, err)
|
||||
fetched <- value
|
||||
}()
|
||||
<-entered
|
||||
|
||||
// The only slot is lent while the fetch waits; without that this call
|
||||
// would wait for its deadline.
|
||||
waitContext, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
value, err := engine.Call(waitContext, "plain")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(42), value)
|
||||
|
||||
close(release)
|
||||
assert.Equal(t, "done", <-fetched)
|
||||
}
|
||||
|
||||
func TestEngineFetchWaitDoesNotCountTowardCallTimeout(t *testing.T) {
|
||||
engine, err := Compile(`export function run() { return utils.fetch({url: "https://api.example/slow"}).status; }`, Options{
|
||||
Key: "fetch-clock", Timeout: 20 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
ctx := WithFetcher(t.Context(), func(context.Context, FetchRequest) (FetchResponse, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return FetchResponse{Status: http.StatusNoContent}, nil
|
||||
})
|
||||
result := make(chan error, 1)
|
||||
var value any
|
||||
go func() {
|
||||
var err error
|
||||
value, err = engine.Call(ctx, "run")
|
||||
result <- err
|
||||
}()
|
||||
<-entered
|
||||
// Five times the JavaScript timeout: the clock is stopped while waiting.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(release)
|
||||
require.NoError(t, <-result)
|
||||
assert.Equal(t, int64(http.StatusNoContent), value)
|
||||
}
|
||||
|
||||
func TestEngineInterruptsAFetchThatCannotResume(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
let calls = 0;
|
||||
export function run(fetch) {
|
||||
calls++;
|
||||
if (!fetch) return calls;
|
||||
try { utils.fetch({url: "https://api.example/slow"}); } catch (e) { return "caught: " + e.message; }
|
||||
return "resumed";
|
||||
}`, Options{Key: "fetch-resume", Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
ctx = WithFetcher(ctx, func(context.Context, FetchRequest) (FetchResponse, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return FetchResponse{Status: http.StatusOK}, nil
|
||||
})
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := engine.Call(ctx, "run", true)
|
||||
result <- err
|
||||
}()
|
||||
<-entered
|
||||
// Another call takes the lent slot and the caller leaves before the fetch
|
||||
// returns, so the slot cannot be taken back.
|
||||
engine.semaphore <- struct{}{}
|
||||
cancel()
|
||||
close(release)
|
||||
err = <-result
|
||||
<-engine.semaphore
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
var hookErr *HookError
|
||||
assert.False(t, errors.As(err, &hookErr), "JavaScript must not catch a failed resume")
|
||||
value, err := engine.Call(t.Context(), "run", false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), value, "an interrupted runtime must be discarded")
|
||||
assert.Empty(t, engine.semaphore)
|
||||
assert.Empty(t, engine.parked)
|
||||
}
|
||||
|
||||
func TestEnginePreservesMutableExportAndMemberBindings(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export let run = function(){return 1;};
|
||||
@@ -495,6 +783,30 @@ export function replace(){run=function(){return 2;};native.render=function(){ret
|
||||
assert.Equal(t, int64(3), value)
|
||||
}
|
||||
|
||||
func TestEngineHasExportUsesBindingsAsLoaded(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export let later;
|
||||
export const unset = undefined;
|
||||
export function run(){return 1;}
|
||||
export function assign(){later=function(){return 2;};}
|
||||
`, Options{Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
engine.semaphore <- struct{}{}
|
||||
assert.True(t, engine.HasExport("run"))
|
||||
assert.False(t, engine.HasExport("later"))
|
||||
assert.False(t, engine.HasExport("unset"))
|
||||
assert.False(t, engine.HasExport("missing"))
|
||||
<-engine.semaphore
|
||||
|
||||
_, err = engine.Call(t.Context(), "assign")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, engine.HasExport("later"))
|
||||
value, err := engine.Call(t.Context(), "later")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), value)
|
||||
}
|
||||
|
||||
func TestEngineHookErrorExtractsSanitizedJSMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
)
|
||||
|
||||
// Fetcher sends one utils.fetch request for the hook call whose context
|
||||
// carries it. The host binds it to the executing channel; the engine passes a
|
||||
// context already bounded by the call's fetch budget, so the fetcher only sends.
|
||||
type Fetcher func(ctx context.Context, request FetchRequest) (FetchResponse, error)
|
||||
|
||||
type FetchRequest struct {
|
||||
Hook string // the calling hook, filled by the engine for logs and errors
|
||||
URL string
|
||||
Method string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type FetchResponse struct {
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// The utils.fetch limits are part of the published plugin contract
|
||||
// (docs/plugin-api/v1.md). A fetcher reads at most MaxFetchBodyBytes+1 bytes
|
||||
// so that the engine can reject a longer body.
|
||||
const (
|
||||
maxFetchesPerCall = 4
|
||||
fetchTimeout = 15 * time.Second
|
||||
maxFetchTimePerCall = 30 * time.Second
|
||||
MaxFetchBodyBytes = 1 << 20
|
||||
maxFetchURLBytes = 64 << 10
|
||||
)
|
||||
|
||||
type fetcherContextKey struct{}
|
||||
|
||||
// WithFetcher makes utils.fetch available to the hook called with ctx.
|
||||
func WithFetcher(ctx context.Context, fetcher Fetcher) context.Context {
|
||||
return context.WithValue(ctx, fetcherContextKey{}, fetcher)
|
||||
}
|
||||
|
||||
// runtimeCall is the hook call a runtime is running, as its host functions
|
||||
// see it. The engine sets it for one call and clears it when the call ends,
|
||||
// so an idle pooled runtime keeps no request data.
|
||||
type runtimeCall struct {
|
||||
engine *Engine
|
||||
runtime *moejs.Runtime
|
||||
ctx context.Context
|
||||
hook string
|
||||
fetcher Fetcher
|
||||
watchdog *runtimeWatchdog
|
||||
// holdsSlot and parked are what the call holds of the engine's
|
||||
// semaphore and parked tokens; release returns exactly that.
|
||||
holdsSlot bool
|
||||
parked bool
|
||||
fetches int
|
||||
fetchTime time.Duration
|
||||
}
|
||||
|
||||
func (c *runtimeCall) release() {
|
||||
if c.holdsSlot {
|
||||
<-c.engine.semaphore
|
||||
c.holdsSlot = false
|
||||
}
|
||||
if c.parked {
|
||||
<-c.engine.parked
|
||||
c.parked = false
|
||||
}
|
||||
}
|
||||
|
||||
// fetch is utils.fetch. While the fetcher waits on the network the call's
|
||||
// JavaScript clock is stopped and its execution slot is lent to other calls.
|
||||
// Errors name the host only: hook errors reach API callers, and paths and
|
||||
// queries can carry credentials.
|
||||
func (c *runtimeCall) fetch(r *moejs.Realm, argument moejs.Value) (moejs.Value, error) {
|
||||
if c.fetcher == nil {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch is not available in %s", c.hook)
|
||||
}
|
||||
request, host, wantBytes, err := fetchRequest(r, argument)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
request.Hook = c.hook
|
||||
if c.fetches >= maxFetchesPerCall {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch allows at most %d requests per hook call", maxFetchesPerCall)
|
||||
}
|
||||
remaining := maxFetchTimePerCall - c.fetchTime
|
||||
if remaining <= 0 {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch requests used up their %s per hook call", maxFetchTimePerCall)
|
||||
}
|
||||
c.fetches++
|
||||
timeout, timeoutCause := fetchTimeout, fmt.Errorf("utils.fetch to %s timed out after %s", host, fetchTimeout)
|
||||
if remaining < fetchTimeout {
|
||||
timeout, timeoutCause = remaining, fmt.Errorf("utils.fetch to %s ran past the %s of requests per hook call", host, maxFetchTimePerCall)
|
||||
}
|
||||
|
||||
if !c.watchdog.pause() {
|
||||
// The call timed out or its caller left; the interrupt ends it.
|
||||
return moejs.Undefined(), r.CheckInterrupt()
|
||||
}
|
||||
c.yieldSlot()
|
||||
fetchContext, cancel := context.WithTimeoutCause(c.ctx, timeout, timeoutCause)
|
||||
started := time.Now()
|
||||
response, err := c.fetcher(fetchContext, request)
|
||||
c.fetchTime += time.Since(started)
|
||||
if err != nil && fetchContext.Err() != nil {
|
||||
err = context.Cause(fetchContext)
|
||||
}
|
||||
cancel()
|
||||
if !c.reclaimSlot() {
|
||||
// JavaScript must not run without a slot, and a thrown error could be
|
||||
// caught; interrupting discards the runtime as a timeout does.
|
||||
cause := context.Cause(c.ctx)
|
||||
if cause == nil {
|
||||
cause = fmt.Errorf("plugin call could not resume after utils.fetch: %w", ErrCallAdmissionTimeout)
|
||||
}
|
||||
c.runtime.Interrupt(cause)
|
||||
return moejs.Undefined(), r.CheckInterrupt()
|
||||
}
|
||||
c.watchdog.resume()
|
||||
if interrupted := r.CheckInterrupt(); interrupted != nil {
|
||||
return moejs.Undefined(), interrupted
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var transportErr *url.Error
|
||||
if errors.As(err, &transportErr) {
|
||||
err = transportErr.Err
|
||||
}
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch to %s failed: %v", host, err)
|
||||
}
|
||||
if len(response.Body) > MaxFetchBodyBytes {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch response from %s exceeds %d bytes", host, MaxFetchBodyBytes)
|
||||
}
|
||||
// The result is built from JavaScript objects, not a host map: a hook may
|
||||
// return it, or part of it, as it is.
|
||||
headers := r.NewObject()
|
||||
for name, value := range response.Headers {
|
||||
if err = headers.CreateDataPropertyOrThrow(r, r.KeyFromGoString(name), moejs.String(value)); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
}
|
||||
headersValue, err := r.FromGo(headers)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
body := moejs.String("")
|
||||
if wantBytes {
|
||||
// An ArrayBuffer over the response bytes, which belong to this call.
|
||||
if response.Body == nil {
|
||||
response.Body = []byte{}
|
||||
}
|
||||
if body, err = r.FromGo(response.Body); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
} else if request.Method != http.MethodHead {
|
||||
text := string(response.Body)
|
||||
if body, err = c.runtime.ParseJSONString(text); err != nil {
|
||||
body = moejs.String(text)
|
||||
}
|
||||
}
|
||||
result := r.NewObject()
|
||||
for _, member := range []struct {
|
||||
name string
|
||||
value moejs.Value
|
||||
}{{"status", moejs.Int(int64(response.Status))}, {"headers", headersValue}, {"body", body}} {
|
||||
if err = result.CreateDataPropertyOrThrow(r, r.KeyFromGoString(member.name), member.value); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
}
|
||||
return r.FromGo(result)
|
||||
}
|
||||
|
||||
// fetchRequest reads the utils.fetch argument: {url, method?, headers?,
|
||||
// responseType?}. wantBytes is a responseType of "bytes": the body is
|
||||
// returned as an ArrayBuffer instead of parsed JSON or text.
|
||||
func fetchRequest(r *moejs.Realm, argument moejs.Value) (request FetchRequest, host string, wantBytes bool, err error) {
|
||||
if !argument.IsObject() {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch request must be an object")
|
||||
}
|
||||
exported, err := r.ToGoStrict(argument)
|
||||
if err != nil {
|
||||
return FetchRequest{}, "", false, err
|
||||
}
|
||||
members, ok := exported.(map[string]any)
|
||||
if !ok {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch request must be an object")
|
||||
}
|
||||
request = FetchRequest{Method: http.MethodGet}
|
||||
for name, value := range members {
|
||||
switch name {
|
||||
case "url":
|
||||
if request.URL, ok = value.(string); !ok {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch url must be a string")
|
||||
}
|
||||
case "method":
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
method, isString := value.(string)
|
||||
if !isString {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch method must be a string")
|
||||
}
|
||||
if method = strings.ToUpper(strings.TrimSpace(method)); method != "" {
|
||||
request.Method = method
|
||||
}
|
||||
case "headers":
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
headers, isObject := value.(map[string]any)
|
||||
if !isObject {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch headers must be an object")
|
||||
}
|
||||
request.Headers = make(map[string]string, len(headers))
|
||||
for headerName, headerValue := range headers {
|
||||
text, isString := headerValue.(string)
|
||||
if !isString {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch header %q must be a string", headerName)
|
||||
}
|
||||
request.Headers[strings.TrimSpace(headerName)] = text
|
||||
}
|
||||
case "responseType":
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
responseType, isString := value.(string)
|
||||
if !isString || (responseType != "json" && responseType != "bytes") {
|
||||
return FetchRequest{}, "", false, r.TypeError(`utils.fetch responseType must be "json" or "bytes"`)
|
||||
}
|
||||
wantBytes = responseType == "bytes"
|
||||
default:
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch request has unsupported member %q", name)
|
||||
}
|
||||
}
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch supports only GET and HEAD")
|
||||
}
|
||||
if len(request.URL) > maxFetchURLBytes {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch url exceeds %d bytes", maxFetchURLBytes)
|
||||
}
|
||||
parsed, err := url.Parse(request.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch url must be an absolute http(s) URL")
|
||||
}
|
||||
if err = ValidateRequestHeaders(request.Headers); err != nil {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch: %s", err)
|
||||
}
|
||||
return request, parsed.Host, wantBytes, nil
|
||||
}
|
||||
|
||||
// yieldSlot lends the call's execution slot to other calls while it waits on
|
||||
// the network. Parked runtimes are bounded by the engine's parked tokens;
|
||||
// without one the call keeps its slot and waits holding it.
|
||||
func (c *runtimeCall) yieldSlot() {
|
||||
select {
|
||||
case c.engine.parked <- struct{}{}:
|
||||
default:
|
||||
return
|
||||
}
|
||||
c.parked = true
|
||||
<-c.engine.semaphore
|
||||
c.holdsSlot = false
|
||||
}
|
||||
|
||||
// reclaimSlot takes an execution slot back for the call's JavaScript, waiting
|
||||
// no longer than the caller and fetchTimeout allow.
|
||||
func (c *runtimeCall) reclaimSlot() bool {
|
||||
if c.holdsSlot {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(fetchTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case c.engine.semaphore <- struct{}{}:
|
||||
c.holdsSlot = true
|
||||
case <-c.ctx.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
<-c.engine.parked
|
||||
c.parked = false
|
||||
return c.holdsSlot
|
||||
}
|
||||
+52
-32
@@ -4,17 +4,18 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Calcium-Ion/moejs/engine"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/grafana/sobek"
|
||||
)
|
||||
|
||||
const (
|
||||
CapabilityJSONClone = "json-clone@1"
|
||||
CapabilitySubmitSSEDelta = "submit-sse-delta@1"
|
||||
CapabilityJSONOrder = "json-order@1"
|
||||
CapabilityDurationAuto = "duration-auto@1"
|
||||
MaxJSONToolBytes = 1 << 20
|
||||
maxJSONToolDepth = 32
|
||||
maxJSONToolNodes = 32768
|
||||
@@ -23,7 +24,7 @@ const (
|
||||
|
||||
// HasCapability describes host APIs independently of a plugin's mutable globals.
|
||||
func HasCapability(name string) bool {
|
||||
return name == CapabilityJSONClone || name == CapabilitySubmitSSEDelta
|
||||
return name == CapabilityJSONClone || name == CapabilitySubmitSSEDelta || name == CapabilityJSONOrder || name == CapabilityDurationAuto
|
||||
}
|
||||
|
||||
// JSONState owns a request-local JSON result. Appended strings stay in Go and
|
||||
@@ -47,6 +48,8 @@ type jsonStateBudget struct {
|
||||
ctx context.Context
|
||||
bytes int
|
||||
nodes int
|
||||
// realm reads JavaScript values; only json.clone passes them.
|
||||
realm *engine.Realm
|
||||
}
|
||||
|
||||
func (b *jsonStateBudget) spend(bytes, nodes int) error {
|
||||
@@ -65,41 +68,58 @@ func newJSONStateNode(value any, depth int, budget *jsonStateBudget) (*jsonState
|
||||
if depth > maxJSONToolDepth {
|
||||
return nil, fmt.Errorf("JSON state exceeds depth limit")
|
||||
}
|
||||
if js, ok := value.(sobek.Value); ok {
|
||||
if sobek.IsUndefined(js) {
|
||||
if js, ok := value.(engine.Value); ok {
|
||||
if js.IsUndefined() {
|
||||
return nil, fmt.Errorf("undefined is not a JSON value")
|
||||
}
|
||||
if object, ok := js.(*sobek.Object); ok {
|
||||
switch object.ClassName() {
|
||||
case "Object":
|
||||
keys := object.Keys()
|
||||
if !js.IsObject() {
|
||||
value = budget.realm.ToGo(js)
|
||||
} else {
|
||||
// Getters and proxy traps run while the value is read; what they
|
||||
// throw is returned as it is, not as a JSON error.
|
||||
object := js.AsObject()
|
||||
switch {
|
||||
case object.ClassName() == "Object" && !object.IsCallable():
|
||||
keys, err := budget.realm.EnumerableOwnKeys(object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(keys) > budget.nodes {
|
||||
return nil, fmt.Errorf("JSON object exceeds node limit")
|
||||
}
|
||||
fields := make(map[string]any, len(keys))
|
||||
for _, key := range keys {
|
||||
fields[key] = object.Get(key)
|
||||
if fields[key.GoString()], err = object.GetProp(budget.realm, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value = fields
|
||||
case "Array":
|
||||
length := object.Get("length").ToInteger()
|
||||
if length < 0 || length > int64(budget.nodes) {
|
||||
case object.ClassName() == "Array":
|
||||
length, err := budget.realm.LengthOfArrayLike(object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if length > int64(budget.nodes) {
|
||||
return nil, fmt.Errorf("JSON array exceeds node limit")
|
||||
}
|
||||
items := make([]any, int(length))
|
||||
for index := range items {
|
||||
item := object.Get(strconv.Itoa(index))
|
||||
if item == nil {
|
||||
key := engine.IndexKey(uint32(index))
|
||||
present, err := budget.realm.HasOwn(object, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !present {
|
||||
return nil, fmt.Errorf("sparse arrays are not JSON values")
|
||||
}
|
||||
items[index] = item
|
||||
if items[index], err = object.GetProp(budget.realm, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value = items
|
||||
default:
|
||||
return nil, fmt.Errorf("json.clone accepts only plain objects, arrays and JSON scalars")
|
||||
}
|
||||
} else {
|
||||
value = js.Export()
|
||||
}
|
||||
}
|
||||
beforeBytes, beforeNodes := budget.bytes, budget.nodes
|
||||
@@ -397,32 +417,32 @@ func (n *jsonStateNode) value() any {
|
||||
return n.scalar
|
||||
}
|
||||
|
||||
// Native JS containers are essential here: wrapping Go slices in ToValue would
|
||||
// retain host-backed array mutation behavior instead of producing a JS clone.
|
||||
func (n *jsonStateNode) jsValue(runtime *sobek.Runtime) (sobek.Value, error) {
|
||||
// Native JS containers are essential here: a lazily converted Go map would
|
||||
// read as a clone, but the result must be ordinary objects a plugin owns.
|
||||
func (n *jsonStateNode) jsValue(realm *engine.Realm) (engine.Value, error) {
|
||||
if n.object != nil {
|
||||
result := runtime.NewObject()
|
||||
result := realm.NewObject()
|
||||
for key, child := range n.object {
|
||||
value, err := child.jsValue(runtime)
|
||||
value, err := child.jsValue(realm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return engine.Undefined(), err
|
||||
}
|
||||
if err = result.DefineDataProperty(key, value, sobek.FLAG_TRUE, sobek.FLAG_TRUE, sobek.FLAG_TRUE); err != nil {
|
||||
return nil, err
|
||||
if err = result.CreateDataPropertyOrThrow(realm, realm.KeyFromGoString(key), value); err != nil {
|
||||
return engine.Undefined(), err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return engine.ObjectValue(result), nil
|
||||
}
|
||||
if n.array != nil {
|
||||
items := make([]any, len(n.array))
|
||||
items := make([]engine.Value, len(n.array))
|
||||
for index, child := range n.array {
|
||||
value, err := child.jsValue(runtime)
|
||||
value, err := child.jsValue(realm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return engine.Undefined(), err
|
||||
}
|
||||
items[index] = value
|
||||
}
|
||||
return runtime.NewArray(items...), nil
|
||||
return engine.ObjectValue(realm.NewArrayFromSlice(items)), nil
|
||||
}
|
||||
return runtime.ToValue(n.value()), nil
|
||||
return realm.FromGo(n.value())
|
||||
}
|
||||
+94
-18
@@ -100,6 +100,7 @@ type Meta struct {
|
||||
Models []string `json:"models"`
|
||||
FetchMode string `json:"fetchMode"`
|
||||
AllowedHosts []string `json:"allowedHosts"`
|
||||
Upstreams []string `json:"upstreams,omitempty"`
|
||||
Routes []Route `json:"routes"`
|
||||
Protocols []ProtocolClaim `json:"protocols"`
|
||||
UsageSchema map[string]UsageFieldSchema `json:"usageSchema,omitempty"`
|
||||
@@ -108,6 +109,47 @@ type Meta struct {
|
||||
Auth AuthMeta `json:"auth"`
|
||||
}
|
||||
|
||||
// Upstream kinds a plugin driver can address. Every driver speaks to its
|
||||
// vendor; a driver that also builds its own native-route URLs when the channel
|
||||
// points at another New API gateway declares UpstreamKindNewAPI in
|
||||
// meta.upstreams, which is what makes it bindable to a type-60 channel.
|
||||
const (
|
||||
UpstreamKindVendor = "vendor"
|
||||
UpstreamKindNewAPI = "new_api"
|
||||
)
|
||||
|
||||
// SupportsUpstream reports whether the driver handles channels of the given
|
||||
// upstream kind. The vendor kind is implied for every plugin.
|
||||
func (m Meta) SupportsUpstream(kind string) bool {
|
||||
return kind == UpstreamKindVendor || slices.Contains(m.Upstreams, kind)
|
||||
}
|
||||
|
||||
// PreservesJSONOrder reports whether the plugin declared json-order@1: its
|
||||
// hooks receive JSON request bodies with members in the order the client sent
|
||||
// them, and its JSON request bodies go upstream as JSON.stringify writes them.
|
||||
// Other plugins keep the cheaper Go-map path, whose members enumerate sorted.
|
||||
func (m Meta) PreservesJSONOrder() bool {
|
||||
return slices.Contains(m.RequiredCapabilities, CapabilityJSONOrder)
|
||||
}
|
||||
|
||||
// AcceptsAutoDuration reports whether the plugin declared duration-auto@1:
|
||||
// request validation lets a duration of -1, the vendor's "the model picks the
|
||||
// length", through to its hooks. The plugin reserves the longest duration the
|
||||
// model can produce and settles the task on the usage the vendor reports.
|
||||
func (m Meta) AcceptsAutoDuration() bool {
|
||||
return slices.Contains(m.RequiredCapabilities, CapabilityDurationAuto)
|
||||
}
|
||||
|
||||
// JSONTextMember names the member of a hook result (a decoded requestBody, a
|
||||
// request descriptor's body) that the host also takes as JSON text, for a
|
||||
// plugin that preserves JSON order; it is empty for other plugins.
|
||||
func (m Meta) JSONTextMember(member string) string {
|
||||
if !m.PreservesJSONOrder() {
|
||||
return ""
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
// UsageProfile replaces the plugin's default usage metadata for its models.
|
||||
type UsageProfile struct {
|
||||
Models []string `json:"models"`
|
||||
@@ -119,11 +161,22 @@ type UsageProfile struct {
|
||||
// must be resolved by the host first; an unknown or ambiguous model uses the
|
||||
// plugin defaults. Profile examples never inherit the default examples.
|
||||
func (m Meta) UsageForModel(model string) (map[string]UsageFieldSchema, []UsageExample) {
|
||||
folded := asciiFold(model)
|
||||
for _, profile := range m.UsageProfiles {
|
||||
for _, declared := range profile.Models {
|
||||
if asciiFold(declared) == folded {
|
||||
return profile.Schema, profile.Examples
|
||||
return m.UsageForModels(model)
|
||||
}
|
||||
|
||||
// UsageForModels returns the usage metadata of the first candidate that a
|
||||
// profile declares. Runtime callers pass the final upstream model before the
|
||||
// client-facing model, so a channel mapping that sends a declared model to a
|
||||
// vendor endpoint ID keeps the declared model's profile. When no candidate is
|
||||
// profiled, the plugin defaults apply.
|
||||
func (m Meta) UsageForModels(models ...string) (map[string]UsageFieldSchema, []UsageExample) {
|
||||
for _, model := range models {
|
||||
folded := asciiFold(model)
|
||||
for _, profile := range m.UsageProfiles {
|
||||
for _, declared := range profile.Models {
|
||||
if asciiFold(declared) == folded {
|
||||
return profile.Schema, profile.Examples
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +226,15 @@ type LoadedPlugin struct {
|
||||
Engine *Engine
|
||||
}
|
||||
|
||||
// UsesSubmitEventDelta reports whether SSE submissions go through
|
||||
// parseSubmitEventDelta: the plugin declares submit-sse-delta@1, or exports
|
||||
// parseSubmitEventDelta without parseSubmitEvent. A plugin that exports both
|
||||
// and declares nothing keeps the snapshot hook, as before.
|
||||
func UsesSubmitEventDelta(meta Meta, engine *Engine) bool {
|
||||
return slices.Contains(meta.RequiredCapabilities, CapabilitySubmitSSEDelta) ||
|
||||
engine.HasExport("parseSubmitEventDelta") && !engine.HasExport("parseSubmitEvent")
|
||||
}
|
||||
|
||||
// RegistrySnapshot is a read-only copy of the metadata currently stored in
|
||||
// each registry layer.
|
||||
type RegistrySnapshot struct {
|
||||
@@ -296,7 +358,7 @@ func CompilePlugin(source string, options Options) (*LoadedPlugin, error) {
|
||||
engine.version = meta.Version
|
||||
requiredHooks := []string{"buildSubmitRequest", "parseSubmitResponse", "parseTaskResult"}
|
||||
if slices.Contains(meta.SubmitResponseTypes, "sse") {
|
||||
if slices.Contains(meta.RequiredCapabilities, CapabilitySubmitSSEDelta) {
|
||||
if UsesSubmitEventDelta(meta, engine) {
|
||||
requiredHooks = append(requiredHooks, "parseSubmitEventDelta")
|
||||
} else {
|
||||
requiredHooks = append(requiredHooks, "parseSubmitEvent")
|
||||
@@ -318,11 +380,7 @@ func CompilePlugin(source string, options Options) (*LoadedPlugin, error) {
|
||||
}
|
||||
artifactHooks := make(map[string]bool, 2)
|
||||
for _, hook := range []string{"listArtifacts", "buildContentRequest"} {
|
||||
exported, exportErr := engine.HasExport(context.Background(), hook)
|
||||
if exportErr != nil {
|
||||
return nil, exportErr
|
||||
}
|
||||
if !exported {
|
||||
if !engine.HasExport(hook) {
|
||||
continue
|
||||
}
|
||||
callable, callableErr := engine.HasCallablePath(context.Background(), hook)
|
||||
@@ -470,11 +528,7 @@ func CompilePlugin(source string, options Options) (*LoadedPlugin, error) {
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{"resolveRequest", "renderError", "renderers"} {
|
||||
has, e := engine.HasExport(context.Background(), removed)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if has {
|
||||
if engine.HasExport(removed) {
|
||||
return nil, fmt.Errorf("plugin %s export %q is no longer supported", meta.Key, removed)
|
||||
}
|
||||
}
|
||||
@@ -834,6 +888,7 @@ func cloneMeta(meta Meta) Meta {
|
||||
meta.ChannelTypes = append([]int(nil), meta.ChannelTypes...)
|
||||
meta.Models = append([]string(nil), meta.Models...)
|
||||
meta.AllowedHosts = append([]string(nil), meta.AllowedHosts...)
|
||||
meta.Upstreams = slices.Clone(meta.Upstreams)
|
||||
meta.Routes = append([]Route(nil), meta.Routes...)
|
||||
for index := range meta.Routes {
|
||||
meta.Routes[index].Models = append([]string(nil), meta.Routes[index].Models...)
|
||||
@@ -975,7 +1030,7 @@ func decodeMeta(value any) (Meta, error) {
|
||||
}
|
||||
for field := range object {
|
||||
switch field {
|
||||
case "requiredCapabilities", "submitResponseTypes", "sortPriority", "website", "apiVersion", "key", "name", "icon", "description", "version", "author", "baseUrl", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "routes", "protocols", "usageSchema", "usageExamples", "usageProfiles", "auth", "endpoints", "submitPaths", "actions":
|
||||
case "requiredCapabilities", "submitResponseTypes", "sortPriority", "website", "apiVersion", "key", "name", "icon", "description", "version", "author", "baseUrl", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "upstreams", "routes", "protocols", "usageSchema", "usageExamples", "usageProfiles", "auth", "endpoints", "submitPaths", "actions":
|
||||
default:
|
||||
return Meta{}, &UnknownMetaFieldError{Field: field}
|
||||
}
|
||||
@@ -1071,6 +1126,10 @@ func decodeMeta(value any) (Meta, error) {
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
meta.Upstreams, err = strictStringSlice(object, "upstreams")
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
meta.Routes, err = decodeRoutes(object["routes"])
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
@@ -1159,6 +1218,13 @@ func normalizeV1Meta(meta *Meta) error {
|
||||
}
|
||||
seenCapabilities[name] = true
|
||||
}
|
||||
seenUpstreams := make(map[string]bool, len(meta.Upstreams))
|
||||
for _, kind := range meta.Upstreams {
|
||||
if (kind != UpstreamKindVendor && kind != UpstreamKindNewAPI) || seenUpstreams[kind] {
|
||||
return fmt.Errorf("unsupported or duplicate upstream kind %q", kind)
|
||||
}
|
||||
seenUpstreams[kind] = true
|
||||
}
|
||||
if meta.SortPriority < math.MinInt32 || meta.SortPriority > math.MaxInt32 {
|
||||
return fmt.Errorf("plugin meta sortPriority must be a signed 32-bit integer")
|
||||
}
|
||||
@@ -1256,6 +1322,9 @@ func normalizeV1Meta(meta *Meta) error {
|
||||
if channelType == constant.ChannelTypeTaskPlugin {
|
||||
return fmt.Errorf("plugin meta channelTypes must not contain the task plugin channel type")
|
||||
}
|
||||
if channelType == constant.ChannelTypeNewAPI {
|
||||
return fmt.Errorf("plugin meta channelTypes must not contain the New API channel type; bind the plugin through task_extend_plugin_keys instead")
|
||||
}
|
||||
if _, duplicate := seenChannelTypes[channelType]; duplicate {
|
||||
return fmt.Errorf("plugin meta channelTypes must be unique")
|
||||
}
|
||||
@@ -1724,7 +1793,7 @@ func decodeRoutes(value any) ([]Route, error) {
|
||||
}
|
||||
for key := range object {
|
||||
switch key {
|
||||
case "method", "path", "type", "action", "decode", "render", "taskIdParam", "models":
|
||||
case "method", "path", "type", "action", "decode", "render", "taskIdParam", "models", "retainResult":
|
||||
default:
|
||||
return nil, fmt.Errorf("plugin meta route %d has unknown field %q", index, key)
|
||||
}
|
||||
@@ -1762,6 +1831,13 @@ func decodeRoutes(value any) ([]Route, error) {
|
||||
return nil, fmt.Errorf("plugin meta route %d models must contain at least one model", index)
|
||||
}
|
||||
}
|
||||
if value, exists := object["retainResult"]; exists {
|
||||
retain, ok := value.(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin meta route %d retainResult must be a boolean", index)
|
||||
}
|
||||
route.RetainResult = &retain
|
||||
}
|
||||
routes = append(routes, route)
|
||||
}
|
||||
return routes, nil
|
||||
|
||||
@@ -589,6 +589,7 @@ func TestRegistryValidatesChannelTypes(t *testing.T) {
|
||||
{name: "zero rejected", channelTypes: []int{0}, wantErr: "positive channel types"},
|
||||
{name: "negative rejected", channelTypes: []int{-1}, wantErr: "positive channel types"},
|
||||
{name: "task plugin type rejected", channelTypes: []int{constant.ChannelTypeTaskPlugin}, wantErr: "task plugin channel type"},
|
||||
{name: "new api type rejected", channelTypes: []int{constant.ChannelTypeNewAPI}, wantErr: "New API channel type"},
|
||||
{name: "duplicates rejected", channelTypes: []int{1, 1}, wantErr: "must be unique"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
@@ -1127,6 +1128,17 @@ usageProfiles: [
|
||||
assert.Empty(t, schema)
|
||||
assert.Nil(t, examples)
|
||||
|
||||
// A channel mapping may send a declared model to an undeclared endpoint ID:
|
||||
// the first profiled candidate wins, otherwise the defaults apply.
|
||||
schema, examples = plugin.Meta.UsageForModels("ep-endpoint", "image")
|
||||
assert.Equal(t, plugin.Meta.UsageProfiles[0].Schema, schema)
|
||||
assert.Nil(t, examples)
|
||||
schema, _ = plugin.Meta.UsageForModels("video", "image")
|
||||
assert.Equal(t, plugin.Meta.UsageProfiles[1].Schema, schema)
|
||||
schema, examples = plugin.Meta.UsageForModels("ep-endpoint", "default")
|
||||
assert.Equal(t, plugin.Meta.UsageSchema, schema)
|
||||
assert.Equal(t, plugin.Meta.UsageExamples, examples)
|
||||
|
||||
snapshot := registry.Snapshot()
|
||||
require.Len(t, snapshot.Override, 1)
|
||||
profile := &snapshot.Override[0].UsageProfiles[1]
|
||||
@@ -1197,6 +1209,33 @@ usageProfiles: [{models:["image"], schema:{tokens:{type:"number", unit:"token"}}
|
||||
require.ErrorContains(t, ValidateV1Meta(plugin.Meta), "schema must be an object")
|
||||
}
|
||||
|
||||
func TestUpstreamsManifestContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, metadata string
|
||||
valid, newAPI bool
|
||||
}{
|
||||
{"omitted implies vendor", "", true, false},
|
||||
{"vendor only", `upstreams:["vendor"],`, true, false},
|
||||
{"vendor and new api", `upstreams:["vendor","new_api"],`, true, true},
|
||||
{"new api only still serves its vendor", `upstreams:["new_api"],`, true, true},
|
||||
{"unknown kind", `upstreams:["gateway"],`, false, false},
|
||||
{"duplicate", `upstreams:["new_api","new_api"],`, false, false},
|
||||
{"null", `upstreams:null,`, false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plugin, err := CompilePlugin(routingTestPluginSource("upstreams", 0, `["model"]`, tc.metadata, ""), Options{})
|
||||
if !tc.valid {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ValidateV1Meta(plugin.Meta))
|
||||
assert.True(t, plugin.Meta.SupportsUpstream(UpstreamKindVendor))
|
||||
assert.Equal(t, tc.newAPI, plugin.Meta.SupportsUpstream(UpstreamKindNewAPI))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitResponseTypesContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, metadata, hook string
|
||||
@@ -1207,6 +1246,9 @@ func TestSubmitResponseTypesContract(t *testing.T) {
|
||||
{"host JSON utility", `requiredCapabilities:["json-clone@1"],`, "", true},
|
||||
{"SSE delta", `submitResponseTypes:["sse"],requiredCapabilities:["submit-sse-delta@1"],`, `export function parseSubmitEventDelta(){return {changes:[],state:null,done:true};}`, true},
|
||||
{"SSE delta missing hook", `submitResponseTypes:["sse"],requiredCapabilities:["submit-sse-delta@1"],`, `export function parseSubmitEvent(){return {state:null,done:true};}`, false},
|
||||
{"SSE delta hook without declaration", `submitResponseTypes:["sse"],`, `export function parseSubmitEventDelta(){return {changes:[],state:null,done:true};}`, true},
|
||||
{"SSE snapshot and delta hooks", `submitResponseTypes:["sse"],`, `export function parseSubmitEvent(){return {state:null,done:true};}
|
||||
export function parseSubmitEventDelta(){return {changes:[],state:null,done:true};}`, true},
|
||||
{"delta requires SSE", `requiredCapabilities:["submit-sse-delta@1"],`, "", false},
|
||||
{"unsupported capability version", `requiredCapabilities:["json-clone@2"],`, "", false},
|
||||
{"duplicate capability", `requiredCapabilities:["json-clone@1","json-clone@1"],`, "", false},
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
// ValidateRequestURL prevents plugins from directing a channel credential to
|
||||
@@ -33,6 +35,30 @@ func ValidateRequestURL(requestURL, baseURL string, allowedHosts []string) error
|
||||
return fmt.Errorf("plugin request host %q is not allowed", request.Host)
|
||||
}
|
||||
|
||||
// ValidateRequestHeaders rejects plugin-chosen headers that the host
|
||||
// transport owns or that change how a request is framed or proxied, and
|
||||
// bounds their number and size.
|
||||
func ValidateRequestHeaders(headers map[string]string) error {
|
||||
if len(headers) > 64 {
|
||||
return fmt.Errorf("at most 64 request headers are allowed")
|
||||
}
|
||||
for name, value := range headers {
|
||||
name = strings.TrimSpace(name)
|
||||
if !httpguts.ValidHeaderFieldName(name) {
|
||||
return fmt.Errorf("request header name %q is invalid", name)
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldValue(value) || len(value) > 8192 {
|
||||
return fmt.Errorf("request header %q has an invalid or oversized value", name)
|
||||
}
|
||||
switch strings.ToLower(name) {
|
||||
case "host", "content-length", "accept-encoding", "connection", "proxy-connection", "keep-alive",
|
||||
"proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade":
|
||||
return fmt.Errorf("request header %q is not allowed", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalHost(value *url.URL) string {
|
||||
host := strings.ToLower(value.Hostname())
|
||||
port := value.Port()
|
||||
|
||||
+91
-37
@@ -1,12 +1,14 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,6 +31,11 @@ type Route struct {
|
||||
Decode string `json:"decode,omitempty"`
|
||||
Render string `json:"render,omitempty"`
|
||||
TaskIDParam string `json:"taskIdParam,omitempty"`
|
||||
// RetainResult, when explicitly false on a submit or dynamic route, tells
|
||||
// the host not to persist the upstream snapshot of an immediate terminal
|
||||
// result and to treat the task as not found on every retrieval surface
|
||||
// afterwards. nil means the route did not declare it (retain).
|
||||
RetainResult *bool `json:"retainResult,omitempty"`
|
||||
// Models restricts this route to the listed models. The host matches the
|
||||
// canonical top-level "model" body field before any JS hook runs; empty
|
||||
// means unrestricted. Must be a subset of meta.models.
|
||||
@@ -88,8 +95,19 @@ var hostProtocols = []HostProtocolDefinition{
|
||||
{Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/videos/:task_id", BodyKinds: []BodyKind{BodyNone}, RequiredProtocolMembers: []string{"render"}},
|
||||
{Name: "content", Methods: []string{http.MethodGet, http.MethodHead}, Path: "/v1/videos/:task_id/content", BodyKinds: []BodyKind{BodyNone}, RequiredDriverHooks: []string{"listArtifacts", "buildContentRequest"}},
|
||||
}},
|
||||
// The OpenAI Images API is synchronous: both operations create a task and
|
||||
// the host answers with the rendered image response once the task is
|
||||
// terminal, so there is no retrieve operation and no request modes.
|
||||
{Name: ProtocolOpenAIImage, Operations: []HostProtocolOperation{
|
||||
{Name: "generate", Methods: []string{http.MethodPost}, Path: "/v1/images/generations", BodyKinds: []BodyKind{BodyJSON}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest", "render"}},
|
||||
{Name: "edit", Methods: []string{http.MethodPost}, Path: "/v1/images/edits", BodyKinds: []BodyKind{BodyJSON, BodyMultipart}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest", "render"}},
|
||||
}},
|
||||
}
|
||||
|
||||
// ProtocolOpenAIImage is the host protocol that serves the OpenAI Images API
|
||||
// (`POST /v1/images/generations` and `POST /v1/images/edits`) from a plugin.
|
||||
const ProtocolOpenAIImage = "openai_image"
|
||||
|
||||
func HostProtocol(name string) (HostProtocolDefinition, bool) {
|
||||
for _, definition := range hostProtocols {
|
||||
if definition.Name == name {
|
||||
@@ -201,6 +219,10 @@ const (
|
||||
ContextKeyPinnedEndpoint = "task_plugin_pinned_endpoint"
|
||||
ContextKeyRouteRequest = "task_plugin_route_request"
|
||||
ContextKeyProtocolRequest = "task_plugin_protocol_request"
|
||||
// ContextKeyRequestBodyText holds, for a plugin that preserves JSON
|
||||
// order, the decoded requestBody as JSON text (json.RawMessage) beside
|
||||
// the Go value in task_request.
|
||||
ContextKeyRequestBodyText = "task_plugin_request_body_text"
|
||||
)
|
||||
|
||||
type PinnedPlugin struct {
|
||||
@@ -228,6 +250,38 @@ type PinnedEndpoint struct {
|
||||
Candidates []ProtocolBinding
|
||||
}
|
||||
|
||||
// FileReference returns the opaque ref of the index-th uploaded file in a
|
||||
// multipart field. The first file keeps the historical `request_file:<field>`
|
||||
// spelling; later files in the same repeated field (`image[]`, `image[]`)
|
||||
// append `#<index>` so a plugin can address each of them.
|
||||
func FileReference(field string, index int) string {
|
||||
if index <= 0 {
|
||||
return "request_file:" + field
|
||||
}
|
||||
return "request_file:" + field + "#" + strconv.Itoa(index)
|
||||
}
|
||||
|
||||
// ParseFileReference resolves a ref produced by FileReference back to the
|
||||
// multipart field and the zero-based file index within that field.
|
||||
func ParseFileReference(ref string) (field string, index int, ok bool) {
|
||||
rest, found := strings.CutPrefix(ref, "request_file:")
|
||||
if !found || rest == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
field, suffix, hasIndex := strings.Cut(rest, "#")
|
||||
if field == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
if !hasIndex {
|
||||
return field, 0, true
|
||||
}
|
||||
parsed, err := strconv.Atoi(suffix)
|
||||
if err != nil || parsed < 0 || strconv.Itoa(parsed) != suffix {
|
||||
return "", 0, false
|
||||
}
|
||||
return field, parsed, true
|
||||
}
|
||||
|
||||
// RouteRequestContext is the canonical request view exposed to declarative
|
||||
// routing hooks. RequestBody contains decoded JSON or multipart text fields;
|
||||
// raw binary and multipart file bytes remain host-owned.
|
||||
@@ -239,55 +293,44 @@ type RouteRequestContext struct {
|
||||
Body any `json:"body"`
|
||||
Files []map[string]any `json:"-"`
|
||||
RequestBody any `json:"-"`
|
||||
// BodyText is the JSON body as the client sent it, which JSValueFor
|
||||
// gives to plugins that preserve JSON order; Body stays the Go value the
|
||||
// host reads. Body storage never changes these bytes, which the engine
|
||||
// parses in place.
|
||||
BodyText json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// JSValue shares the request with hooks without copying it: the engine never
|
||||
// writes JavaScript changes back into Go values, so the request must only stay
|
||||
// unchanged while a hook runs. Missing params and query still reach hooks as
|
||||
// empty objects rather than null.
|
||||
func (r RouteRequestContext) JSValue() map[string]any {
|
||||
params := make(map[string]string, len(r.Params))
|
||||
maps.Copy(params, r.Params)
|
||||
query := make(map[string][]string, len(r.Query))
|
||||
for key, values := range r.Query {
|
||||
query[key] = append([]string(nil), values...)
|
||||
params, query := r.Params, r.Query
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
if query == nil {
|
||||
query = map[string][]string{}
|
||||
}
|
||||
return map[string]any{
|
||||
"path": r.Path,
|
||||
"method": r.Method,
|
||||
"params": params,
|
||||
"query": query,
|
||||
"body": clonePluginRequestValue(r.Body),
|
||||
"body": r.Body,
|
||||
}
|
||||
}
|
||||
|
||||
func clonePluginRequestValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
cloned[key] = clonePluginRequestValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = clonePluginRequestValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
case map[string][]string:
|
||||
cloned := make(map[string][]string, len(typed))
|
||||
for key, values := range typed {
|
||||
cloned[key] = append([]string(nil), values...)
|
||||
}
|
||||
return cloned
|
||||
case []map[string]any:
|
||||
cloned := make([]map[string]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = clonePluginRequestValue(item).(map[string]any)
|
||||
}
|
||||
return cloned
|
||||
default:
|
||||
return value
|
||||
// JSValueFor is JSValue for the decode hooks of the plugin meta describes. A
|
||||
// plugin that preserves JSON order receives a JSON body as its text, which the
|
||||
// engine parses in place, members in the client's order: a decoder reads the
|
||||
// body, so it is parsed for every call.
|
||||
func (r RouteRequestContext) JSValueFor(meta Meta) map[string]any {
|
||||
value := r.JSValue()
|
||||
if len(r.BodyText) > 0 && meta.PreservesJSONOrder() {
|
||||
value["body"] = map[string]any{"kind": string(BodyJSON), "value": RawJSON(r.BodyText)}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type ProtocolRequestContext struct {
|
||||
@@ -303,7 +346,15 @@ type ProtocolRequestContext struct {
|
||||
}
|
||||
|
||||
func (p ProtocolRequestContext) JSValue() map[string]any {
|
||||
value := p.RouteRequestContext.JSValue()
|
||||
return p.withProtocol(p.RouteRequestContext.JSValue())
|
||||
}
|
||||
|
||||
// JSValueFor is RouteRequestContext.JSValueFor with the protocol fields.
|
||||
func (p ProtocolRequestContext) JSValueFor(meta Meta) map[string]any {
|
||||
return p.withProtocol(p.RouteRequestContext.JSValueFor(meta))
|
||||
}
|
||||
|
||||
func (p ProtocolRequestContext) withProtocol(value map[string]any) map[string]any {
|
||||
value["protocol"] = p.Protocol
|
||||
value["operation"] = p.Operation
|
||||
value["model"] = p.Model
|
||||
@@ -712,6 +763,9 @@ func validateRoute(route *Route) error {
|
||||
if route.Action != "" {
|
||||
return fmt.Errorf("query route %s %s must not declare action", route.Method, route.Path)
|
||||
}
|
||||
if route.RetainResult != nil {
|
||||
return fmt.Errorf("query route %s %s must not declare retainResult", route.Method, route.Path)
|
||||
}
|
||||
if route.TaskIDParam == "" {
|
||||
route.TaskIDParam = "task_id"
|
||||
}
|
||||
|
||||
+129
-19
@@ -81,6 +81,7 @@ func TestRegistryIndexesSharedEndpointCandidatesForDistinctLegacyProviders(t *te
|
||||
func TestSupportsRegisteredHostProtocols(t *testing.T) {
|
||||
assert.True(t, SupportsHostProtocol("openai_responses"))
|
||||
assert.True(t, SupportsHostProtocol("openai_video"))
|
||||
assert.True(t, SupportsHostProtocol(ProtocolOpenAIImage))
|
||||
assert.False(t, SupportsHostProtocol("plugin_owned_wire"))
|
||||
}
|
||||
|
||||
@@ -89,6 +90,71 @@ func TestLookupHostProtocolOperationExcludesRetrieveWithoutModelField(t *testing
|
||||
assert.False(t, ok)
|
||||
_, _, ok = LookupHostProtocolOperation(http.MethodPost, "/v1/responses")
|
||||
assert.True(t, ok)
|
||||
for _, path := range []string{"/v1/images/generations", "/v1/images/edits"} {
|
||||
protocol, operation, found := LookupHostProtocolOperation(http.MethodPost, path)
|
||||
require.True(t, found, path)
|
||||
assert.Equal(t, ProtocolOpenAIImage, protocol)
|
||||
assert.Equal(t, "model", operation.ModelField)
|
||||
assert.Empty(t, operation.Modes, "the synchronous image protocol has no request modes")
|
||||
}
|
||||
}
|
||||
|
||||
// The OpenAI Images protocol is claimed in bare-string form, binds both image
|
||||
// endpoints for every declared model, and requires the render hook that shapes
|
||||
// the synchronous response.
|
||||
func TestOpenAIImageProtocolClaimBindsBothEndpointsAndRequiresRender(t *testing.T) {
|
||||
exports := `export const protocols = {openai_image: {
|
||||
decodeRequest: function(ctx) { return {kind: "submit", model: ctx.model, requestBody: ctx.body.value}; },
|
||||
render: function(ctx, task) { return {data: []}; }
|
||||
}};`
|
||||
registry := NewRegistry()
|
||||
plugin := mustCompileRoutingPlugin(t, "image-plugin", 17, `["image-a", "image-b"]`, `protocols: ["openai_image"],`, exports)
|
||||
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
|
||||
for _, path := range []string{"/v1/images/generations", "/v1/images/edits"} {
|
||||
for _, model := range []string{"image-a", "image-b"} {
|
||||
binding, ok := registry.Generation().LookupEndpoint(http.MethodPost, path, model)
|
||||
require.True(t, ok, path+" "+model)
|
||||
assert.Equal(t, ProtocolOpenAIImage, binding.Protocol)
|
||||
assert.Same(t, plugin, binding.Plugin)
|
||||
}
|
||||
}
|
||||
_, ok := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/videos", "image-a")
|
||||
assert.False(t, ok, "an image claim must not bind the video endpoint")
|
||||
|
||||
scoped := mustCompileRoutingPlugin(t, "image-scoped", 17, `["image-a", "image-b"]`, `protocols: [{name: "openai_image", models: ["image-a"]}],`, exports)
|
||||
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{scoped}))
|
||||
_, ok = registry.Generation().LookupEndpoint(http.MethodPost, "/v1/images/generations", "image-b")
|
||||
assert.False(t, ok, "models narrows the image endpoint bindings")
|
||||
|
||||
_, err := CompilePlugin(routingTestPluginSource("image-no-render", 17, `["image-a"]`, `protocols: ["openai_image"],`,
|
||||
`export const protocols = {openai_image: {decodeRequest: function(ctx) { return {kind: "submit", model: ctx.model}; }}};`), Options{})
|
||||
require.ErrorContains(t, err, `missing hook "render"`)
|
||||
_, err = CompilePlugin(routingTestPluginSource("image-modes", 17, `["image-a"]`, `protocols: [{name: "openai_image", supports: ["sync"]}],`, exports), Options{})
|
||||
require.ErrorContains(t, err, "does not define modes")
|
||||
}
|
||||
|
||||
func TestFileReferenceRoundTripAddressesRepeatedFieldFiles(t *testing.T) {
|
||||
assert.Equal(t, "request_file:image[]", FileReference("image[]", 0))
|
||||
assert.Equal(t, "request_file:image[]#2", FileReference("image[]", 2))
|
||||
for _, tc := range []struct {
|
||||
ref string
|
||||
field string
|
||||
index int
|
||||
ok bool
|
||||
}{
|
||||
{"request_file:input_reference", "input_reference", 0, true},
|
||||
{"request_file:image[]#1", "image[]", 1, true},
|
||||
{"request_file:image[]#01", "", 0, false},
|
||||
{"request_file:image[]#-1", "", 0, false},
|
||||
{"request_file:#1", "", 0, false},
|
||||
{"request_file:", "", 0, false},
|
||||
{"image", "", 0, false},
|
||||
} {
|
||||
field, index, ok := ParseFileReference(tc.ref)
|
||||
assert.Equal(t, tc.ok, ok, tc.ref)
|
||||
assert.Equal(t, tc.field, field, tc.ref)
|
||||
assert.Equal(t, tc.index, index, tc.ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerProtocolModelsNarrowEndpointBindings(t *testing.T) {
|
||||
@@ -348,38 +414,82 @@ func TestRouteModelsDecodeAndValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteRequestContextClonesFormAndMultipartValuesPerDecoder(t *testing.T) {
|
||||
func TestRouteRetainResultDecodeAndValidation(t *testing.T) {
|
||||
routeExports := `export const native = {
|
||||
decodeJob: function(ctx) { return {kind: "submit", model: "gpt-5.5", requestBody: ctx.body.value}; },
|
||||
jobCreated: function(ctx, task) { return task; },
|
||||
jobStatus: function(ctx, task) { return task; }
|
||||
};`
|
||||
falseValue := false
|
||||
tests := []struct {
|
||||
name string
|
||||
body any
|
||||
mutate func(map[string]any)
|
||||
name string
|
||||
metaFields string
|
||||
want *bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "form fields",
|
||||
body: map[string]any{"kind": "form", "fields": map[string][]string{"prompt": {"original"}}},
|
||||
mutate: func(value map[string]any) {
|
||||
value["body"].(map[string]any)["fields"].(map[string][]string)["prompt"][0] = "mutated"
|
||||
},
|
||||
name: "undeclared route retains",
|
||||
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", decode: "decodeJob", render: "jobCreated"}],`,
|
||||
},
|
||||
{
|
||||
name: "multipart files",
|
||||
body: map[string]any{"kind": "multipart", "files": []map[string]any{{"ref": "request_file:image", "filename": "safe.png"}}},
|
||||
mutate: func(value map[string]any) {
|
||||
value["body"].(map[string]any)["files"].([]map[string]any)[0]["filename"] = "mutated.png"
|
||||
},
|
||||
name: "submit route declines retention",
|
||||
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", decode: "decodeJob", render: "jobCreated", retainResult: false}],`,
|
||||
want: &falseValue,
|
||||
},
|
||||
{
|
||||
name: "query route rejects retainResult",
|
||||
metaFields: `routes: [{method: "GET", path: "/v1/batch/:task_id", type: "query", render: "jobStatus", retainResult: true}],`,
|
||||
errContains: "must not declare retainResult",
|
||||
},
|
||||
{
|
||||
name: "non-boolean value",
|
||||
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", decode: "decodeJob", render: "jobCreated", retainResult: "no"}],`,
|
||||
errContains: "retainResult must be a boolean",
|
||||
},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
request := RouteRequestContext{Body: testCase.body}
|
||||
first := request.JSValue()
|
||||
testCase.mutate(first)
|
||||
second := request.JSValue()
|
||||
assert.NotEqual(t, first, second)
|
||||
plugin, err := CompilePlugin(
|
||||
routingTestPluginSource("route-retain", 0, `["gpt-5.5"]`, testCase.metaFields, routeExports),
|
||||
Options{},
|
||||
)
|
||||
if testCase.errContains != "" {
|
||||
require.ErrorContains(t, err, testCase.errContains)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plugin.Meta.Routes, 1)
|
||||
assert.Equal(t, testCase.want, plugin.Meta.Routes[0].RetainResult)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteRequestContextKeepsFormAndMultipartValuesAcrossDecoderWrites(t *testing.T) {
|
||||
plugin, err := CompilePlugin(routingTestPluginSource("route-request-writes", 0, `["gpt-5.5"]`, "", `
|
||||
export function decodeJob(ctx) {
|
||||
const seen = {
|
||||
prompt: ctx.body.fields.prompt[0],
|
||||
filename: ctx.body.files[0].filename,
|
||||
params: Object.keys(ctx.params).length,
|
||||
query: Object.keys(ctx.query).length
|
||||
};
|
||||
ctx.body.fields.prompt[0] = "mutated";
|
||||
ctx.body.files[0].filename = "mutated.png";
|
||||
return seen;
|
||||
}`), Options{})
|
||||
require.NoError(t, err)
|
||||
fields := map[string][]string{"prompt": {"original"}}
|
||||
files := []map[string]any{{"ref": "request_file:image", "filename": "safe.png"}}
|
||||
request := RouteRequestContext{Body: map[string]any{"kind": "multipart", "fields": fields, "files": files}}
|
||||
for range 2 {
|
||||
seen, err := plugin.Engine.Call(context.Background(), "decodeJob", request.JSValue())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"prompt": "original", "filename": "safe.png", "params": int64(0), "query": int64(0)}, seen)
|
||||
}
|
||||
assert.Equal(t, "original", fields["prompt"][0])
|
||||
assert.Equal(t, "safe.png", files[0]["filename"])
|
||||
}
|
||||
|
||||
func TestRegistryNoOpMutationsKeepCurrentGeneration(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
plugin := mustCompileRoutingPlugin(t, "stable-generation", 0, `["model"]`, "", "")
|
||||
|
||||
+199
-46
@@ -6,15 +6,17 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/grafana/sobek"
|
||||
)
|
||||
|
||||
type volcSignRequest struct {
|
||||
@@ -29,65 +31,216 @@ type volcSignRequest struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
func injectGlobals(runtime *sobek.Runtime, identity func() string, now func() time.Time, logOutput func(string)) error {
|
||||
// injectGlobals installs utils and console. currentCall returns the hook call
|
||||
// running on runtime, or nil outside one; utils.fetch reads its fetcher and
|
||||
// budget there, so no request data lives in the runtime's globals.
|
||||
func injectGlobals(runtime *moejs.Runtime, identity func() string, now func() time.Time, logOutput func(string), currentCall func() *runtimeCall) error {
|
||||
utils := map[string]any{
|
||||
"hasCapability": HasCapability,
|
||||
"fetch": runtime.Function("fetch", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
call := currentCall()
|
||||
if call == nil {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch is not available outside hooks")
|
||||
}
|
||||
return call.fetch(r, moejs.Arg(args, 0))
|
||||
}),
|
||||
"hasCapability": runtime.Function("hasCapability", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
name, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.Bool(HasCapability(name)), nil
|
||||
}),
|
||||
"json": map[string]any{
|
||||
"clone": func(call sobek.FunctionCall) sobek.Value {
|
||||
if sobek.IsUndefined(call.Argument(0)) {
|
||||
panic(runtime.NewTypeError("json.clone requires a JSON value"))
|
||||
"clone": runtime.Function("clone", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
if moejs.Arg(args, 0).IsUndefined() {
|
||||
return moejs.Undefined(), r.TypeError("json.clone requires a JSON value")
|
||||
}
|
||||
budget := &jsonStateBudget{ctx: context.Background(), bytes: MaxJSONToolBytes, nodes: maxJSONToolNodes}
|
||||
value, err := newJSONStateNode(call.Argument(0), 0, budget)
|
||||
budget := &jsonStateBudget{ctx: context.Background(), bytes: MaxJSONToolBytes, nodes: maxJSONToolNodes, realm: r}
|
||||
value, err := newJSONStateNode(moejs.Arg(args, 0), 0, budget)
|
||||
if err != nil {
|
||||
panic(runtime.NewTypeError("json.clone: %s", err))
|
||||
var exception *moejs.Exception
|
||||
var interrupted *moejs.InterruptedError
|
||||
if errors.As(err, &exception) || errors.As(err, &interrupted) {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.Undefined(), r.TypeError("json.clone: %s", err)
|
||||
}
|
||||
cloned, err := value.jsValue(runtime)
|
||||
return value.jsValue(r)
|
||||
}),
|
||||
},
|
||||
"unixNow": runtime.Function("unixNow", 0, func(*moejs.Realm, moejs.Value, []moejs.Value) (moejs.Value, error) {
|
||||
return moejs.Int(now().Unix()), nil
|
||||
}),
|
||||
"jwtSignHS256": runtime.Function("jwtSignHS256", 2, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
var claims map[string]any
|
||||
if argument := moejs.Arg(args, 0); !argument.IsNullish() {
|
||||
exported, err := r.ToGoStrict(argument)
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return cloned
|
||||
},
|
||||
},
|
||||
"unixNow": func() int64 { return now().Unix() },
|
||||
"jwtSignHS256": func(claims map[string]any, secret string) (string, error) {
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(secret))
|
||||
},
|
||||
"hmacSHA256": func(message, secret string) string {
|
||||
var ok bool
|
||||
if claims, ok = exported.(map[string]any); !ok {
|
||||
return moejs.Undefined(), r.TypeError("jwtSignHS256 claims must be an object")
|
||||
}
|
||||
}
|
||||
secret, err := stringValue(r, moejs.Arg(args, 1))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.String(token), nil
|
||||
}),
|
||||
"hmacSHA256": runtime.Function("hmacSHA256", 2, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
message, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
secret, err := stringValue(r, moejs.Arg(args, 1))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(message))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
},
|
||||
"base64": func(value string) string { return base64.StdEncoding.EncodeToString([]byte(value)) },
|
||||
"base64URL": func(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
},
|
||||
"base64URLDecode": func(value string) (string, error) {
|
||||
return moejs.String(hex.EncodeToString(mac.Sum(nil))), nil
|
||||
}),
|
||||
"base64": runtime.Function("base64", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
value, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.String(base64.StdEncoding.EncodeToString([]byte(value))), nil
|
||||
}),
|
||||
"base64URL": runtime.Function("base64URL", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
value, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.String(base64.RawURLEncoding.EncodeToString([]byte(value))), nil
|
||||
}),
|
||||
"base64URLDecode": runtime.Function("base64URLDecode", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
value, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
return string(decoded), err
|
||||
},
|
||||
"uuid": func() string { return uuid.NewString() },
|
||||
"volcSignV4": func(request volcSignRequest) (map[string]string, error) {
|
||||
return signVolcV4(request, now)
|
||||
},
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.String(string(decoded)), nil
|
||||
}),
|
||||
"uuid": runtime.Function("uuid", 0, func(*moejs.Realm, moejs.Value, []moejs.Value) (moejs.Value, error) {
|
||||
return moejs.String(uuid.NewString()), nil
|
||||
}),
|
||||
"volcSignV4": runtime.Function("volcSignV4", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
// Fields are read by their Go names, as plugins have always
|
||||
// passed them.
|
||||
var request volcSignRequest
|
||||
argument := moejs.Arg(args, 0)
|
||||
if !argument.IsNullish() && !argument.IsObject() {
|
||||
return moejs.Undefined(), r.TypeError("volcSignV4 request must be an object")
|
||||
}
|
||||
if argument.IsObject() {
|
||||
object := argument.AsObject()
|
||||
fields := []struct {
|
||||
name string
|
||||
target *string
|
||||
}{
|
||||
{"Method", &request.Method}, {"URL", &request.URL}, {"Body", &request.Body},
|
||||
{"AccessKey", &request.AccessKey}, {"SecretKey", &request.SecretKey},
|
||||
{"Region", &request.Region}, {"Service", &request.Service},
|
||||
}
|
||||
for _, field := range fields {
|
||||
value, err := object.GetProp(r, r.KeyFromGoString(field.name))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if *field.target, err = stringValue(r, value); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
}
|
||||
headers, err := object.GetProp(r, r.KeyFromGoString("Headers"))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if headers.IsObject() {
|
||||
keys, err := r.EnumerableOwnKeys(headers.AsObject())
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
request.Headers = make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
value, err := headers.AsObject().GetProp(r, key)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if request.Headers[key.GoString()], err = stringValue(r, value); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
}
|
||||
}
|
||||
timestamp, err := object.GetProp(r, r.KeyFromGoString("Timestamp"))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if !timestamp.IsNullish() {
|
||||
seconds, err := r.ToNumber(timestamp)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if !math.IsNaN(seconds) && !math.IsInf(seconds, 0) {
|
||||
request.Timestamp = int64(seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
signed, err := signVolcV4(request, now)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return r.FromGo(signed)
|
||||
}),
|
||||
}
|
||||
if err := runtime.Set("utils", utils); err != nil {
|
||||
if err := runtime.SetGlobal("utils", utils); err != nil {
|
||||
return err
|
||||
}
|
||||
console := runtime.NewObject()
|
||||
if err := console.Set("log", func(call sobek.FunctionCall) sobek.Value {
|
||||
parts := make([]string, len(call.Arguments))
|
||||
for i, argument := range call.Arguments {
|
||||
parts[i] = argument.String()
|
||||
}
|
||||
if logOutput != nil {
|
||||
logOutput(identity() + " " + strings.Join(parts, " "))
|
||||
}
|
||||
return sobek.Undefined()
|
||||
}); err != nil {
|
||||
return err
|
||||
return runtime.SetGlobal("console", map[string]any{
|
||||
"log": runtime.Function("log", 0, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
parts := make([]string, len(args))
|
||||
for i, argument := range args {
|
||||
if argument.IsSymbol() {
|
||||
parts[i] = argument.String()
|
||||
continue
|
||||
}
|
||||
text, err := r.ToString(argument)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
parts[i] = text.GoString()
|
||||
}
|
||||
if logOutput != nil {
|
||||
logOutput(identity() + " " + strings.Join(parts, " "))
|
||||
}
|
||||
return moejs.Undefined(), nil
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// stringValue reads a host function's string parameter: undefined and null
|
||||
// read as "", other values as JavaScript's String(value).
|
||||
func stringValue(r *moejs.Realm, value moejs.Value) (string, error) {
|
||||
if value.IsNullish() {
|
||||
return "", nil
|
||||
}
|
||||
return runtime.Set("console", console)
|
||||
if value.IsString() {
|
||||
return value.AsString().GoString(), nil
|
||||
}
|
||||
text, err := r.ToString(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return text.GoString(), nil
|
||||
}
|
||||
|
||||
func signVolcV4(request volcSignRequest, now func() time.Time) (map[string]string, error) {
|
||||
|
||||
@@ -13,6 +13,7 @@ var ErrInvalidSample = errors.New("invalid probe answer")
|
||||
var ErrIncomplete = errors.New("probe response incomplete")
|
||||
|
||||
type ProbeResponse struct {
|
||||
FirstTokenMS *int64 `json:"-"`
|
||||
Text string `json:"-"`
|
||||
DeclaredModel string `json:"declared_model,omitempty"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
@@ -59,7 +60,7 @@ func completedResponse(response gjson.Result) (ProbeResponse, error) {
|
||||
|
||||
// Only a complete response is a sample. Partial deltas, tool calls and a
|
||||
// truncated stream cannot become a model verdict. Total bytes are bounded.
|
||||
func ReadResponse(reader io.Reader, contentType string) (ProbeResponse, error) {
|
||||
func ReadResponse(reader io.Reader, contentType string, onFirstText ...func()) (ProbeResponse, error) {
|
||||
limited := &io.LimitedReader{R: reader, N: 8 << 20}
|
||||
buffered := bufio.NewReader(limited)
|
||||
peek, _ := buffered.Peek(1)
|
||||
@@ -78,6 +79,7 @@ func ReadResponse(reader io.Reader, contentType string) (ProbeResponse, error) {
|
||||
var data strings.Builder
|
||||
var deltas strings.Builder
|
||||
refused := false
|
||||
firstText := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
@@ -104,6 +106,12 @@ func ReadResponse(reader io.Reader, contentType string) (ProbeResponse, error) {
|
||||
event := gjson.Parse(payload)
|
||||
typ := event.Get("type").String()
|
||||
if typ == "response.output_text.delta" {
|
||||
if !firstText && event.Get("delta").String() != "" {
|
||||
firstText = true
|
||||
for _, observe := range onFirstText {
|
||||
observe()
|
||||
}
|
||||
}
|
||||
deltas.WriteString(event.Get("delta").String())
|
||||
if deltas.Len() > 65536 {
|
||||
return ProbeResponse{}, ErrInvalidSample
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package perfmetrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -52,7 +51,7 @@ func flushCompletedBuckets() {
|
||||
})
|
||||
if err != nil {
|
||||
bucket.addCounters(drained)
|
||||
common.SysError(fmt.Sprintf("failed to flush perf metric bucket model=%s group=%s bucket=%d: %s", k.model, k.group, k.bucketTs, err.Error()))
|
||||
common.SysError(common.LogText("failed to flush perf metric bucket model=%s group=%s bucket=%d: %s", k.model, k.group, k.bucketTs, err.Error()))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -73,7 +72,7 @@ func cleanupExpiredMetrics(retentionDays int) {
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour).Unix()
|
||||
if err := model.DeletePerfMetricsBefore(cutoff); err != nil {
|
||||
common.SysError("failed to cleanup expired perf metrics: " + err.Error())
|
||||
common.SysError(common.LogText("failed to cleanup expired perf metrics: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+108
-8
@@ -1,6 +1,7 @@
|
||||
package perfmetrics
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -23,6 +24,11 @@ var hotBuckets sync.Map
|
||||
// hiding fields or making response-only privacy hardening changes.
|
||||
const seriesSchema = "dbcd0a3c01b55203"
|
||||
|
||||
// MaxRecentModels bounds the historical lookups made by one summary request.
|
||||
const MaxRecentModels = 50
|
||||
const recentSuccessSampleLimit = 24
|
||||
const recentSuccessHistoryHours = 7 * 24
|
||||
|
||||
func Init() {
|
||||
go flushLoop()
|
||||
}
|
||||
@@ -67,6 +73,41 @@ func RecordRelayResult(ctx context.Context, info *relaycommon.RelayInfo, apiErr
|
||||
})
|
||||
}
|
||||
|
||||
// RecordTaskResult samples one async task exactly once, at its terminal
|
||||
// transition, after the polling status CAS is won. Latency is the end-to-end
|
||||
// task duration (second resolution, unlike the ms-resolution relay samples);
|
||||
// throughput is only present when the provider reports tokens.
|
||||
func RecordTaskResult(task *model.Task, result *relaycommon.TaskInfo) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
modelName := task.Properties.OriginModelName
|
||||
if bc := task.PrivateData.BillingContext; bc != nil && bc.OriginModelName != "" {
|
||||
modelName = bc.OriginModelName
|
||||
}
|
||||
endTs := task.FinishTime
|
||||
if endTs <= 0 {
|
||||
endTs = time.Now().Unix()
|
||||
}
|
||||
sample := Sample{
|
||||
Model: modelName,
|
||||
Group: task.Group,
|
||||
Success: task.Status == model.TaskStatusSuccess,
|
||||
}
|
||||
if task.SubmitTime > 0 && endTs > task.SubmitTime {
|
||||
sample.LatencyMs = (endTs - task.SubmitTime) * 1000
|
||||
}
|
||||
if sample.Success && result != nil {
|
||||
tokens := int64(cmp.Or(result.TotalTokens, result.CompletionTokens))
|
||||
genStart := cmp.Or(task.StartTime, task.SubmitTime)
|
||||
if tokens > 0 && genStart > 0 && endTs > genStart {
|
||||
sample.OutputTokens = tokens
|
||||
sample.GenerationMs = (endTs - genStart) * 1000
|
||||
}
|
||||
}
|
||||
Record(sample)
|
||||
}
|
||||
|
||||
func Record(sample Sample) {
|
||||
setting := perf_metrics_setting.GetSetting()
|
||||
if !setting.Enabled || sample.Model == "" {
|
||||
@@ -154,16 +195,43 @@ func Query(params QueryParams) (QueryResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
startTs, endTs := queryWindow(time.Now(), hours)
|
||||
func QuerySummaryAll(hours int, groups []string, recentModels ...string) (SummaryAllResult, error) {
|
||||
if len(recentModels) > MaxRecentModels {
|
||||
return SummaryAllResult{}, common.NewMessage("Invalid parameters")
|
||||
}
|
||||
now := time.Now()
|
||||
startTs, endTs := queryWindow(now, hours)
|
||||
recentStartTs, _ := queryWindow(now, recentSuccessHistoryHours)
|
||||
allowedGroups := allowedGroupSet(groups)
|
||||
queryStartTs := startTs
|
||||
|
||||
rows, err := model.GetPerfMetricsSummaryBucketsAll(startTs, endTs, groups)
|
||||
recentBuckets := make(map[string]map[int64]counters, len(recentModels))
|
||||
for _, name := range recentModels {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := recentBuckets[name]; exists {
|
||||
continue
|
||||
}
|
||||
recentBuckets[name] = map[int64]counters{}
|
||||
buckets, err := model.GetRecentPerfMetricSuccessBuckets(name, recentStartTs, endTs, groups, recentSuccessSampleLimit)
|
||||
if err != nil {
|
||||
return SummaryAllResult{}, err
|
||||
}
|
||||
for _, bucket := range buckets {
|
||||
queryStartTs = min(queryStartTs, bucket.BucketTs)
|
||||
mergeModelBucket(recentBuckets, name, bucket.BucketTs, counters{
|
||||
requestCount: bucket.RequestCount,
|
||||
successCount: bucket.SuccessCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := model.GetPerfMetricsSummaryBucketsAll(queryStartTs, endTs, groups)
|
||||
if err != nil {
|
||||
return SummaryAllResult{}, err
|
||||
}
|
||||
|
||||
totals := map[string]counters{}
|
||||
modelBuckets := map[string]map[int64]counters{}
|
||||
for _, row := range rows {
|
||||
value := counters{
|
||||
@@ -173,13 +241,12 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
outputTokens: row.OutputTokens,
|
||||
generationMs: row.GenerationMs,
|
||||
}
|
||||
mergeModelTotals(totals, row.ModelName, value)
|
||||
mergeModelBucket(modelBuckets, row.ModelName, row.BucketTs, value)
|
||||
}
|
||||
|
||||
hotBuckets.Range(func(key, value any) bool {
|
||||
k := key.(bucketKey)
|
||||
if k.bucketTs < startTs || k.bucketTs > endTs {
|
||||
if k.bucketTs > endTs {
|
||||
return true
|
||||
}
|
||||
if allowedGroups != nil {
|
||||
@@ -191,11 +258,41 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
if snap.requestCount == 0 {
|
||||
return true
|
||||
}
|
||||
mergeModelTotals(totals, k.model, snap)
|
||||
if _, requested := recentBuckets[k.model]; requested && k.bucketTs >= recentStartTs {
|
||||
mergeModelBucket(recentBuckets, k.model, k.bucketTs, snap)
|
||||
}
|
||||
if k.bucketTs < queryStartTs {
|
||||
return true
|
||||
}
|
||||
mergeModelBucket(modelBuckets, k.model, k.bucketTs, snap)
|
||||
return true
|
||||
})
|
||||
|
||||
recentSeries := make(map[string][]SuccessRatePoint, len(recentBuckets))
|
||||
for name, buckets := range recentBuckets {
|
||||
points := recentSuccessSeries(buckets)
|
||||
if len(points) == 0 {
|
||||
continue
|
||||
}
|
||||
recentSeries[name] = points[max(0, len(points)-recentSuccessSampleLimit):]
|
||||
}
|
||||
|
||||
// Requested models are summarized over the same hours as their status samples.
|
||||
totals := map[string]counters{}
|
||||
for name, buckets := range modelBuckets {
|
||||
fromTs := startTs
|
||||
if points, ok := recentSeries[name]; ok {
|
||||
fromTs = points[0].Ts
|
||||
}
|
||||
for bucketTs, value := range buckets {
|
||||
if bucketTs < fromTs {
|
||||
delete(buckets, bucketTs)
|
||||
continue
|
||||
}
|
||||
mergeModelTotals(totals, name, value)
|
||||
}
|
||||
}
|
||||
|
||||
all := counters{}
|
||||
models := make([]ModelSummary, 0, len(totals))
|
||||
for name, total := range totals {
|
||||
@@ -226,7 +323,10 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
return models[i].RequestCount > models[j].RequestCount
|
||||
})
|
||||
|
||||
return SummaryAllResult{Summary: summarize(all), WindowStart: startTs, WindowEnd: endTs, Models: models}, nil
|
||||
return SummaryAllResult{
|
||||
Summary: summarize(all), WindowStart: startTs, WindowEnd: endTs,
|
||||
Models: models, RecentSuccessSeries: recentSeries,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryGroups combines raw bucket counters so busy models carry their actual weight.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -109,6 +110,52 @@ func TestHourlySuccessSeriesWeightsSmallerBuckets(t *testing.T) {
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: 3600, SuccessRate: 99.01}, {Ts: 7200, SuccessRate: 100}}, points)
|
||||
}
|
||||
|
||||
func TestRecentHistoryRejectsOversizedModelLists(t *testing.T) {
|
||||
_, err := QuerySummaryAll(24, nil, make([]string, 51)...)
|
||||
require.EqualError(t, err, "Invalid parameters")
|
||||
}
|
||||
|
||||
// Terminal task sampling: success/failure counts, end-to-end latency, and
|
||||
// token throughput only for successful tasks that report tokens.
|
||||
func TestRecordTaskResultSamplesTerminalTasks(t *testing.T) {
|
||||
hotBuckets.Clear()
|
||||
t.Cleanup(func() { hotBuckets.Clear() })
|
||||
now := time.Now().Unix()
|
||||
RecordTaskResult(&model.Task{
|
||||
Status: model.TaskStatusSuccess,
|
||||
Group: "a",
|
||||
SubmitTime: now - 120,
|
||||
StartTime: now - 100,
|
||||
FinishTime: now,
|
||||
Properties: model.Properties{OriginModelName: "video-model"},
|
||||
}, &relaycommon.TaskInfo{TotalTokens: 5000})
|
||||
RecordTaskResult(&model.Task{
|
||||
Status: model.TaskStatusFailure,
|
||||
Group: "a",
|
||||
SubmitTime: now - 60,
|
||||
FinishTime: now,
|
||||
Properties: model.Properties{OriginModelName: "video-model"},
|
||||
}, relaycommon.FailTaskInfo("boom"))
|
||||
RecordTaskResult(&model.Task{Status: model.TaskStatusSuccess, FinishTime: now}, nil)
|
||||
|
||||
merged := map[bucketKey]counters{}
|
||||
hotBuckets.Range(func(key, value any) bool {
|
||||
k := key.(bucketKey)
|
||||
require.Equal(t, "video-model", k.model)
|
||||
require.Equal(t, "a", k.group)
|
||||
k.bucketTs = 0
|
||||
mergeCounters(merged, k, value.(*atomicBucket).snapshot())
|
||||
return true
|
||||
})
|
||||
assert.Equal(t, counters{
|
||||
requestCount: 2,
|
||||
successCount: 1,
|
||||
totalLatencyMs: 180000,
|
||||
outputTokens: 5000,
|
||||
generationMs: 100000,
|
||||
}, merged[bucketKey{model: "video-model", group: "a"}])
|
||||
}
|
||||
|
||||
// TEST_PERF_MYSQL_DSN / TEST_PERF_POSTGRES_DSN optionally run the aggregation
|
||||
// against isolated real MySQL/PostgreSQL databases.
|
||||
func TestPerformanceAggregationAndFlush(t *testing.T) {
|
||||
@@ -140,6 +187,13 @@ func TestPerformanceAggregationAndFlush(t *testing.T) {
|
||||
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
|
||||
require.NoError(t, db.Migrator().DropTable(&model.PerfMetric{}))
|
||||
require.NoError(t, db.AutoMigrate(&model.PerfMetric{}))
|
||||
versionQuery := "SELECT version()"
|
||||
if dialect.name == "sqlite" {
|
||||
versionQuery = "SELECT sqlite_version()"
|
||||
}
|
||||
var version string
|
||||
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
|
||||
t.Logf("database version: %s", version)
|
||||
|
||||
now := time.Now()
|
||||
start, _ := queryWindow(now, 24)
|
||||
@@ -208,6 +262,91 @@ func TestPerformanceAggregationAndFlush(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 98.04, combined.Summary.SuccessRate)
|
||||
assert.Equal(t, 99.01, combined.Models[0].SuccessRate)
|
||||
|
||||
t.Run("recent models are summarized over their status sample hours", func(t *testing.T) {
|
||||
for i := range 26 {
|
||||
require.NoError(t, model.UpsertPerfMetric(&model.PerfMetric{
|
||||
ModelName: "history-model", Group: "a", BucketTs: hour - int64(i+1)*6*3600,
|
||||
RequestCount: 1, SuccessCount: 1,
|
||||
}))
|
||||
}
|
||||
for _, row := range []model.PerfMetric{
|
||||
{ModelName: "history-model", Group: "a", BucketTs: hour + 60, RequestCount: 100, SuccessCount: 100},
|
||||
{ModelName: "history-model", Group: "inactive", BucketTs: hour + 120, RequestCount: 100},
|
||||
{ModelName: "history-model", Group: "a", BucketTs: hour + 2*3600, RequestCount: 1},
|
||||
{ModelName: "history-idle", Group: "a", BucketTs: hour - 3*24*3600, RequestCount: 1},
|
||||
{ModelName: "history-hidden", Group: "inactive", BucketTs: hour - 3*24*3600, RequestCount: 1},
|
||||
} {
|
||||
require.NoError(t, model.UpsertPerfMetric(&row))
|
||||
}
|
||||
require.NoError(t, db.Create(&model.PerfMetric{ModelName: "history-model", Group: "a", BucketTs: hour - 3600}).Error)
|
||||
hotFailure := &atomicBucket{}
|
||||
hotFailure.add(Sample{})
|
||||
hotBuckets.Store(bucketKey{model: "history-model", group: "b", bucketTs: hour + 120}, hotFailure)
|
||||
hotSuccess := &atomicBucket{}
|
||||
hotSuccess.add(Sample{Success: true})
|
||||
hotBuckets.Store(bucketKey{model: "history-hot", group: "a", bucketTs: hour}, hotSuccess)
|
||||
hiddenFailure := &atomicBucket{}
|
||||
hiddenFailure.add(Sample{})
|
||||
hotBuckets.Store(bucketKey{model: "history-hidden", group: "inactive", bucketTs: hour}, hiddenFailure)
|
||||
|
||||
window, err := QuerySummaryAll(24, groups)
|
||||
require.NoError(t, err)
|
||||
history, err := QuerySummaryAll(24, groups, "history-model", "history-idle", "history-hidden", "history-hot", "missing")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, window.WindowStart, history.WindowStart)
|
||||
summaries := lo.KeyBy(history.Models, func(m ModelSummary) string { return m.ModelName })
|
||||
assert.Equal(t, int64(124), summaries["history-model"].RequestCount)
|
||||
assert.Equal(t, 99.19, summaries["history-model"].SuccessRate)
|
||||
assert.Equal(t, int64(1), summaries["history-idle"].RequestCount)
|
||||
assert.Equal(t, 0.0, summaries["history-idle"].SuccessRate)
|
||||
assert.NotContains(t, summaries, "history-hidden")
|
||||
points := history.RecentSuccessSeries["history-model"]
|
||||
require.Len(t, points, 24)
|
||||
expected := make([]SuccessRatePoint, 0, 24)
|
||||
for i := 23; i > 0; i-- {
|
||||
expected = append(expected, SuccessRatePoint{Ts: hour - int64(i)*6*3600, SuccessRate: 100})
|
||||
}
|
||||
expected = append(expected, SuccessRatePoint{Ts: hour, SuccessRate: 99.01})
|
||||
assert.Equal(t, expected, points)
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: hour - 3*24*3600, SuccessRate: 0}}, history.RecentSuccessSeries["history-idle"])
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: hour, SuccessRate: 100}}, history.RecentSuccessSeries["history-hot"])
|
||||
assert.Empty(t, history.RecentSuccessSeries["history-hidden"])
|
||||
assert.Empty(t, history.RecentSuccessSeries["missing"])
|
||||
|
||||
denied, err := QuerySummaryAll(24, []string{}, "history-model", "history-hidden")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, denied.RecentSuccessSeries)
|
||||
flushCompletedBuckets()
|
||||
persisted, err := QuerySummaryAll(24, groups, "history-model", "history-hot")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, points, persisted.RecentSuccessSeries["history-model"])
|
||||
assert.Equal(t, history.RecentSuccessSeries["history-hot"], persisted.RecentSuccessSeries["history-hot"])
|
||||
})
|
||||
|
||||
t.Run("recent history stops at the 7 day boundary even with fewer than 24 samples", func(t *testing.T) {
|
||||
historyStart, _ := queryWindow(now, 7*24)
|
||||
for _, row := range []model.PerfMetric{
|
||||
{ModelName: "history-boundary", Group: "a", BucketTs: historyStart, RequestCount: 1, SuccessCount: 1},
|
||||
{ModelName: "history-boundary", Group: "a", BucketTs: historyStart - 3600, RequestCount: 1},
|
||||
{ModelName: "history-expired", Group: "a", BucketTs: historyStart - 3600, RequestCount: 1},
|
||||
} {
|
||||
require.NoError(t, model.UpsertPerfMetric(&row))
|
||||
}
|
||||
hotExpired := &atomicBucket{}
|
||||
hotExpired.add(Sample{Success: true})
|
||||
hotBuckets.Store(bucketKey{model: "history-hot-expired", group: "a", bucketTs: historyStart - 3600}, hotExpired)
|
||||
hotBoundary := &atomicBucket{}
|
||||
hotBoundary.add(Sample{Success: true})
|
||||
hotBuckets.Store(bucketKey{model: "history-hot-boundary", group: "a", bucketTs: historyStart}, hotBoundary)
|
||||
|
||||
history, err := QuerySummaryAll(24, groups, "history-boundary", "history-expired", "history-hot-expired", "history-hot-boundary")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: historyStart, SuccessRate: 100}}, history.RecentSuccessSeries["history-boundary"])
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: historyStart, SuccessRate: 100}}, history.RecentSuccessSeries["history-hot-boundary"])
|
||||
assert.Empty(t, history.RecentSuccessSeries["history-expired"])
|
||||
assert.Empty(t, history.RecentSuccessSeries["history-hot-expired"])
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,19 +18,25 @@ const (
|
||||
)
|
||||
|
||||
// ClassifyRelayOutcome decides whether one finished relay counts as a health
|
||||
// sample. Business rejections and client cancellations are not samples; the
|
||||
// classification is independent of retries, channel disabling and billing.
|
||||
// sample. Business rejections and cancellations during generation are not
|
||||
// samples; a stream already completed normally without errors remains eligible
|
||||
// after its context is canceled. Classification is independent of retries,
|
||||
// channel disabling and billing.
|
||||
func ClassifyRelayOutcome(ctx context.Context, info *relaycommon.RelayInfo, apiErr *types.NewAPIError) Outcome {
|
||||
if info == nil || info.PerformanceBusinessRejection {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
if ctx != nil && ctx.Err() == context.Canceled {
|
||||
stream := info.StreamStatus.OutcomeSnapshot()
|
||||
completedNormally := info.IsStream && info.StreamStatus != nil && apiErr == nil &&
|
||||
stream.Response == relaycommon.ResponseOutcomeCompleted &&
|
||||
(stream.EndReason == relaycommon.StreamEndReasonEOF || stream.EndReason == relaycommon.StreamEndReasonDone) &&
|
||||
!stream.HasErrors && info.StreamStatus.EndError == nil
|
||||
if ctx != nil && ctx.Err() == context.Canceled && !completedNormally {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
if apiErr != nil && errors.Is(apiErr, context.Canceled) {
|
||||
return OutcomeIgnored
|
||||
}
|
||||
stream := info.StreamStatus.OutcomeSnapshot()
|
||||
if stream.Response == relaycommon.ResponseOutcomeFailed {
|
||||
return classifyFailure(false, stream.ErrorCode, stream.ErrorType, stream.ErrorStatus)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ type SummaryAllResult struct {
|
||||
WindowStart int64 `json:"window_start"`
|
||||
WindowEnd int64 `json:"window_end"`
|
||||
Models []ModelSummary `json:"models"`
|
||||
// RecentSuccessSeries contains up to 24 nonempty hours from the last 7 days,
|
||||
// including requested models with no traffic in the summary window.
|
||||
RecentSuccessSeries map[string][]SuccessRatePoint `json:"recent_success_series,omitempty"`
|
||||
}
|
||||
|
||||
type bucketKey struct {
|
||||
|
||||
@@ -97,7 +97,7 @@ func CloseChannels(channelIDs []int, reason string) int {
|
||||
e.close(reason)
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
common.SysLog(fmt.Sprintf("closed %d active websocket connection(s), channels=%v, kinds=%v, reason=%s", len(entries), entryChannelIDs(entries), entryKindCounts(entries), reason))
|
||||
common.SysLog(common.LogText("closed %d active websocket connection(s), channels=%v, kinds=%v, reason=%s", len(entries), entryChannelIDs(entries), entryKindCounts(entries), reason))
|
||||
}
|
||||
return len(entries)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func CloseChannels(channelIDs []int, reason string) int {
|
||||
func CloseChannelsAndBroadcast(channelIDs []int, reason string) int {
|
||||
count := CloseChannels(channelIDs, reason)
|
||||
if err := PublishCloseChannels(context.Background(), channelIDs, reason); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to publish websocket close event: %v", err))
|
||||
common.SysLog(common.LogText("failed to publish websocket close event: %v", err))
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func receiveChannelCloseEvents(ctx context.Context, ch <-chan *redis.Message, or
|
||||
}
|
||||
var event closeEvent
|
||||
if err := common.Unmarshal([]byte(msg.Payload), &event); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to unmarshal websocket close event: %v", err))
|
||||
common.SysLog(common.LogText("failed to unmarshal websocket close event: %v", err))
|
||||
continue
|
||||
}
|
||||
if event.Origin == origin {
|
||||
|
||||
Reference in new issue
Block a user