feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
+48
-24
@@ -7,7 +7,6 @@ import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
@@ -117,7 +116,7 @@ type Engine struct {
|
||||
now func() time.Time
|
||||
log func(string)
|
||||
module *sobek.SourceTextModuleRecord
|
||||
pool sync.Pool
|
||||
pool chan *runtimeInstance
|
||||
semaphore chan struct{}
|
||||
}
|
||||
|
||||
@@ -172,13 +171,14 @@ func Compile(source string, options Options) (*Engine, error) {
|
||||
log: options.Log,
|
||||
module: module,
|
||||
semaphore: make(chan struct{}, concurrency),
|
||||
pool: make(chan *runtimeInstance, concurrency),
|
||||
}
|
||||
instance, err := engine.newRuntime(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance.logContext.context = nil
|
||||
engine.pool.Put(instance)
|
||||
engine.putRuntime(instance)
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
@@ -201,14 +201,12 @@ func (e *Engine) Export(ctx context.Context, exportName string) (result any, err
|
||||
instance.runtime.ClearInterrupt()
|
||||
instance.logContext.context = nil
|
||||
if reusable {
|
||||
e.pool.Put(instance)
|
||||
e.putRuntime(instance)
|
||||
}
|
||||
}()
|
||||
timedOut := errors.New("plugin export timed out")
|
||||
timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
|
||||
stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
|
||||
defer stopContext()
|
||||
defer timer.Stop()
|
||||
stopInterrupt := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer stopInterrupt()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reusable = false
|
||||
@@ -244,7 +242,7 @@ func (e *Engine) HasExport(ctx context.Context, exportName string) (bool, error)
|
||||
}
|
||||
defer func() {
|
||||
instance.logContext.context = nil
|
||||
e.pool.Put(instance)
|
||||
e.putRuntime(instance)
|
||||
}()
|
||||
value := instance.module.GetBindingValue(exportName)
|
||||
return value != nil && !sobek.IsUndefined(value), nil
|
||||
@@ -268,14 +266,12 @@ func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members
|
||||
instance.runtime.ClearInterrupt()
|
||||
instance.logContext.context = nil
|
||||
if reusable {
|
||||
e.pool.Put(instance)
|
||||
e.putRuntime(instance)
|
||||
}
|
||||
}()
|
||||
timedOut := errors.New("plugin inspection timed out")
|
||||
timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
|
||||
stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
|
||||
defer stopContext()
|
||||
defer timer.Stop()
|
||||
stopInterrupt := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer stopInterrupt()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reusable = false
|
||||
@@ -350,16 +346,14 @@ func (e *Engine) call(
|
||||
instance.runtime.ClearInterrupt()
|
||||
instance.logContext.context = nil
|
||||
if reusable {
|
||||
e.pool.Put(instance)
|
||||
e.putRuntime(instance)
|
||||
}
|
||||
}()
|
||||
|
||||
hookName := strings.Join(append([]string{exportName}, members...), ".")
|
||||
timedOut := errors.New("plugin call timed out")
|
||||
timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
|
||||
stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
|
||||
defer stopContext()
|
||||
defer timer.Stop()
|
||||
stopInterrupt := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer stopInterrupt()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reusable = false
|
||||
@@ -459,13 +453,40 @@ func resolveExportPath(instance *runtimeInstance, exportName string, members []s
|
||||
return value, hookName, true
|
||||
}
|
||||
|
||||
// Idle runtimes are bounded by the execution limit and survive GC. Start with
|
||||
// one instance and grow only when concurrent work actually needs more.
|
||||
func (e *Engine) putRuntime(instance *runtimeInstance) {
|
||||
select {
|
||||
case e.pool <- instance:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) getRuntime(ctx context.Context) (*runtimeInstance, error) {
|
||||
if pooled := e.pool.Get(); pooled != nil {
|
||||
instance := pooled.(*runtimeInstance)
|
||||
select {
|
||||
case instance := <-e.pool:
|
||||
instance.logContext.context = ctx
|
||||
return instance, nil
|
||||
default:
|
||||
return e.newRuntime(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
interrupted := make(chan struct{})
|
||||
stop := context.AfterFunc(callContext, func() {
|
||||
runtime.Interrupt(context.Cause(callContext))
|
||||
close(interrupted)
|
||||
})
|
||||
return func() {
|
||||
if !stop() {
|
||||
<-interrupted
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
return e.newRuntime(ctx)
|
||||
}
|
||||
|
||||
func (e *Engine) newRuntime(ctx context.Context) (instance *runtimeInstance, err error) {
|
||||
@@ -483,8 +504,11 @@ func (e *Engine) newRuntime(ctx context.Context) (instance *runtimeInstance, err
|
||||
return nil, fmt.Errorf("inject plugin utils: %w", err)
|
||||
}
|
||||
timedOut := errors.New("plugin initialization timed out")
|
||||
timer := time.AfterFunc(e.timeout, func() { runtime.Interrupt(timedOut) })
|
||||
defer timer.Stop()
|
||||
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 {
|
||||
|
||||
+325
-2
@@ -4,13 +4,21 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/gin-gonic/gin"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -27,6 +35,7 @@ export function sign(ctx) {
|
||||
encoded: utils.base64(ctx.message),
|
||||
};
|
||||
}
|
||||
|
||||
export const meta = { apiVersion: 1, key: "mock" };
|
||||
`, Options{
|
||||
Key: "mock", Version: "1.0.0",
|
||||
@@ -49,6 +58,253 @@ export const meta = { apiVersion: 1, key: "mock" };
|
||||
assert.Equal(t, map[string]any{"apiVersion": int64(1), "key": "mock"}, meta)
|
||||
}
|
||||
|
||||
func TestEngineJSONCloneProducesIndependentNativeContainers(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export function clone(input) {
|
||||
const result = utils.json.clone(input);
|
||||
result.items.push({text:"appended"});
|
||||
result.items[0].text = "changed";
|
||||
return {result, array:Array.isArray(result.items),
|
||||
prototype:Object.getPrototypeOf(result) === Object.prototype,
|
||||
ownProto:Object.prototype.hasOwnProperty.call(result,"__proto__"),
|
||||
available:utils.hasCapability("json-clone@1"), unavailable:utils.hasCapability("json-clone@2")};
|
||||
}
|
||||
export function invalid(kind) {
|
||||
let value;
|
||||
switch(kind) {
|
||||
case "cycle": value={}; value.self=value; break;
|
||||
case "function": value={run:function(){}}; break;
|
||||
case "undefined": value={missing:undefined}; break;
|
||||
case "nan": value=NaN; break;
|
||||
case "bigint": value=1n; break;
|
||||
case "date": value=new Date(); break;
|
||||
case "sparse": value=[,1]; break;
|
||||
case "size": value="<".repeat(200000); break;
|
||||
case "nodes": value=new Array(32768).fill(0); break;
|
||||
}
|
||||
return utils.json.clone(value);
|
||||
}`, Options{})
|
||||
require.NoError(t, err)
|
||||
input := map[string]any{"items": []any{map[string]any{"text": "original"}}, "__proto__": map[string]any{"marker": "data"}, "zero": math.Copysign(0, -1)}
|
||||
value, err := engine.Call(t.Context(), "clone", input)
|
||||
require.NoError(t, err)
|
||||
result := value.(map[string]any)
|
||||
assert.Equal(t, true, result["array"])
|
||||
assert.Equal(t, true, result["prototype"])
|
||||
assert.Equal(t, true, result["ownProto"])
|
||||
assert.Equal(t, true, result["available"])
|
||||
assert.Equal(t, false, result["unavailable"])
|
||||
cloned := result["result"].(map[string]any)
|
||||
assert.Equal(t, []any{map[string]any{"text": "changed"}, map[string]any{"text": "appended"}}, cloned["items"])
|
||||
assert.Equal(t, map[string]any{"marker": "data"}, cloned["__proto__"])
|
||||
assert.True(t, math.Signbit(cloned["zero"].(float64)))
|
||||
assert.Equal(t, []any{map[string]any{"text": "original"}}, input["items"])
|
||||
for _, kind := range []string{"cycle", "function", "undefined", "nan", "bigint", "date", "sparse", "size", "nodes"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
_, err := engine.Call(t.Context(), "invalid", kind)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStateChangesAndEncodedLimits(t *testing.T) {
|
||||
const changes = `[
|
||||
[{"op":"set","path":[],"value":{"text":"<","parts":[],"nested":{"value":null}}}],
|
||||
[{"op":"appendText","path":["text"],"value":"&\n\"图像😀\u2028"},
|
||||
{"op":"append","path":["parts"],"value":{"text":"a","enabled":false}},
|
||||
{"op":"set","path":["nested","value"],"value":[1,2]}],
|
||||
[{"op":"appendText","path":["parts",0,"text"],"value":"b"},
|
||||
{"op":"set","path":["nested","value",0],"value":0},
|
||||
{"op":"set","path":["nested","a<b"],"value":{}}]
|
||||
]`
|
||||
var frames []any
|
||||
require.NoError(t, common.UnmarshalJsonStr(changes, &frames))
|
||||
const expected = `{"text":"<&\n\"图像😀\u2028","parts":[{"text":"ab","enabled":false}],"nested":{"value":[0,2],"a<b":{}}}`
|
||||
var want any
|
||||
require.NoError(t, common.UnmarshalJsonStr(expected, &want))
|
||||
encoded, err := common.Marshal(want)
|
||||
require.NoError(t, err)
|
||||
state := NewJSONState(len(encoded))
|
||||
for _, frame := range frames {
|
||||
require.NoError(t, state.Apply(t.Context(), frame))
|
||||
_, err = state.Value()
|
||||
require.NoError(t, err, "every intermediate frame must match the codec's actual byte size")
|
||||
}
|
||||
value, err := state.Value()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, value)
|
||||
// One extra byte fails at the update boundary and invalidates publication.
|
||||
require.Error(t, state.Apply(t.Context(), []any{map[string]any{"op": "appendText", "path": []any{"text"}, "value": "x"}}))
|
||||
_, err = state.Value()
|
||||
require.Error(t, err)
|
||||
|
||||
t.Run("replacement releases byte and node budget", func(t *testing.T) {
|
||||
state := NewJSONState(16)
|
||||
for _, value := range []any{"abcdefghijklmn", map[string]any{}, []any{int64(0), false, nil}, []any(nil), map[string]any(nil)} {
|
||||
require.NoError(t, state.Apply(t.Context(), []any{map[string]any{"op": "set", "path": []any{}, "value": value}}))
|
||||
_, err := state.Value()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
t.Run("encoded limit includes HTML escaping", func(t *testing.T) {
|
||||
state := NewJSONState(13)
|
||||
require.NoError(t, state.Apply(t.Context(), []any{map[string]any{"op": "set", "path": []any{}, "value": "<"}}))
|
||||
require.Error(t, state.Apply(t.Context(), []any{map[string]any{"op": "appendText", "path": []any{}, "value": "<"}}))
|
||||
})
|
||||
t.Run("request cancellation", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
state := NewJSONState(64)
|
||||
require.ErrorIs(t, state.Apply(ctx, []any{}), context.Canceled)
|
||||
})
|
||||
t.Run("bounded operation and result complexity", func(t *testing.T) {
|
||||
many := make([]any, 257)
|
||||
for index := range many {
|
||||
many[index] = map[string]any{"op": "set", "path": []any{}, "value": nil}
|
||||
}
|
||||
require.Error(t, NewJSONState(1024).Apply(t.Context(), many))
|
||||
var deep any = nil
|
||||
for range 33 {
|
||||
deep = []any{deep}
|
||||
}
|
||||
for _, value := range []any{deep, make([]any, 32768), math.Inf(1)} {
|
||||
state := NewJSONState(1 << 20)
|
||||
require.Error(t, state.Apply(t.Context(), []any{map[string]any{"op": "set", "path": []any{}, "value": value}}))
|
||||
}
|
||||
})
|
||||
for _, invalid := range []string{
|
||||
`[{"op":"remove","path":[],"value":null}]`,
|
||||
`[{"op":"set","path":[],"value":null,"extra":true}]`,
|
||||
`[{"op":"set","path":["missing","child"],"value":1}]`,
|
||||
`[{"op":"set","path":["parts",-1],"value":1}]`,
|
||||
`[{"op":"set","path":["parts",0.5],"value":1}]`,
|
||||
`[{"op":"set","path":["parts",100000000000000000000],"value":1}]`,
|
||||
`[{"op":"appendText","path":["parts"],"value":"x"}]`,
|
||||
`[{"op":"append","path":["text"],"value":"x"}]`,
|
||||
} {
|
||||
t.Run(invalid, func(t *testing.T) {
|
||||
state := NewJSONState(1024)
|
||||
require.NoError(t, state.Apply(t.Context(), frames[0]))
|
||||
var changes any
|
||||
require.NoError(t, common.UnmarshalJsonStr(invalid, &changes))
|
||||
require.Error(t, state.Apply(t.Context(), changes))
|
||||
_, err := state.Value()
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type bytePreservingJSONCodec struct {
|
||||
jsoniter.API
|
||||
}
|
||||
|
||||
func (c bytePreservingJSONCodec) Decode(reader io.Reader, value any) error {
|
||||
return c.NewDecoder(reader).Decode(value)
|
||||
}
|
||||
|
||||
func TestJSONStateUTF8Normalization(t *testing.T) {
|
||||
const codecEnv = "JSPLUGIN_TEST_BYTE_PRESERVING_JSON"
|
||||
if os.Getenv(codecEnv) == "1" {
|
||||
// Only this test runs in the child process; the parent's codec and
|
||||
// parallel tests are unaffected. Disabling HTML escaping in jsoniter
|
||||
// also preserves invalid UTF-8, independently of Sonic's platform support.
|
||||
kitutil.SetCodec(bytePreservingJSONCodec{jsoniter.Config{SortMapKeys: true}.Froze()})
|
||||
encoded, err := common.Marshal("\xff\xfe")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("\"\xff\xfe\""), encoded)
|
||||
var decoded any
|
||||
require.NoError(t, common.Unmarshal(encoded, &decoded))
|
||||
require.Equal(t, "\xff\xfe", decoded)
|
||||
} else {
|
||||
t.Run("byte preserving codec", func(t *testing.T) {
|
||||
executable, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
command := exec.CommandContext(t.Context(), executable, "-test.run=^TestJSONStateUTF8Normalization$", "-test.v")
|
||||
command.Env = append(os.Environ(), codecEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
require.NoError(t, err, "%s", output)
|
||||
})
|
||||
}
|
||||
|
||||
const raw = "图像\xff\xfe😀<&\n\"\\\u2028"
|
||||
const normalized = "图像\ufffd\ufffd😀<&\n\"\\\u2028"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
op string
|
||||
path []any
|
||||
initial any
|
||||
value any
|
||||
want any
|
||||
}{
|
||||
{"set", "set", []any{}, false, raw, normalized},
|
||||
{"append", "append", []any{}, []any{float64(0), false}, raw, []any{float64(0), false, normalized}},
|
||||
{"appendText", "appendText", []any{"a<b"}, map[string]any{"a<b": "前\xff"}, raw, map[string]any{"a<b": "前\ufffd" + normalized}},
|
||||
{"separate invalid bytes", "appendText", []any{}, "\xff", "\xfe", "\ufffd\ufffd"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
encoded, err := common.Marshal(tc.want)
|
||||
require.NoError(t, err)
|
||||
for _, shortBy := range []int{0, 1} {
|
||||
state := NewJSONState(len(encoded) - shortBy)
|
||||
err := state.Apply(t.Context(), []any{
|
||||
map[string]any{"op": "set", "path": []any{}, "value": tc.initial},
|
||||
map[string]any{"op": tc.op, "path": tc.path, "value": tc.value},
|
||||
})
|
||||
if shortBy == 0 {
|
||||
require.NoError(t, err, "normalized JSON must fit exactly")
|
||||
value, err := state.Value()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, value)
|
||||
continue
|
||||
}
|
||||
require.Error(t, err, "normalized JSON must exceed the limit by one byte")
|
||||
value, valueErr := state.Value()
|
||||
require.ErrorIs(t, valueErr, err)
|
||||
assert.Nil(t, value, "a failed batch must not publish its successful prefix")
|
||||
require.ErrorIs(t, state.Apply(t.Context(), []any{
|
||||
map[string]any{"op": "set", "path": []any{}, "value": nil},
|
||||
}), err, "a later replacement must not revive failed state")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("clone", func(t *testing.T) {
|
||||
engine, err := Compile(`export function clone(input) { return utils.json.clone(input); }`, Options{})
|
||||
require.NoError(t, err)
|
||||
input := map[string]any{"text": raw, "items": []any{float64(0), false, raw}}
|
||||
value, err := engine.Call(t.Context(), "clone", input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"text": normalized, "items": []any{int64(0), false, normalized}}, value)
|
||||
assert.Equal(t, raw, input["text"])
|
||||
|
||||
encoded, err := common.Marshal(normalized)
|
||||
require.NoError(t, err)
|
||||
padding := strings.Repeat("x", MaxJSONToolBytes-len(encoded))
|
||||
value, err = engine.Call(t.Context(), "clone", raw+padding)
|
||||
require.NoError(t, err, "normalized clone must fit exactly")
|
||||
assert.Equal(t, normalized, strings.TrimSuffix(value.(string), padding))
|
||||
value, err = engine.Call(t.Context(), "clone", raw+padding+"x")
|
||||
require.Error(t, err, "normalized clone must exceed the limit by one byte")
|
||||
assert.Nil(t, value)
|
||||
})
|
||||
|
||||
t.Run("invalid keys and paths remain rejected", func(t *testing.T) {
|
||||
for _, change := range []any{
|
||||
map[string]any{"op": "set", "path": []any{}, "value": map[string]any{"\xff\xfe": raw}},
|
||||
map[string]any{"op": "set", "path": []any{"\xff\xfe"}, "value": raw},
|
||||
} {
|
||||
state := NewJSONState(128)
|
||||
require.Error(t, state.Apply(t.Context(), []any{
|
||||
map[string]any{"op": "set", "path": []any{}, "value": map[string]any{"\ufffd\ufffd": "original"}},
|
||||
change,
|
||||
}))
|
||||
value, err := state.Value()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngineConsoleLogUsesDebugLoggerAndRequestContext(t *testing.T) {
|
||||
previousDebug := common.DebugEnabled
|
||||
common.DebugEnabled = false
|
||||
@@ -160,16 +416,83 @@ func TestCompileIgnoresSourceMapDirectives(t *testing.T) {
|
||||
|
||||
func TestEngineInterruptsLongRunningHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine, err := Compile(`export function run() { while (true) {} }`, Options{
|
||||
engine, err := Compile(`let calls = 0; export function run(loop) { calls++; if (loop) { while (true) {} } return calls; }`, Options{
|
||||
Key: "loop", Version: "1", Timeout: 20 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = engine.Call(context.Background(), "run")
|
||||
_, err = engine.Call(context.Background(), "run", true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timed out")
|
||||
var hookErr *HookError
|
||||
assert.False(t, errors.As(err, &hookErr), "timeouts must not be HookError")
|
||||
value, err := engine.Call(context.Background(), "run", false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), value, "an interrupted runtime must be discarded")
|
||||
}
|
||||
|
||||
func TestEngineDoesNotReinitializeIdleRuntimeAfterGC(t *testing.T) {
|
||||
var initialized atomic.Int64
|
||||
engine, err := Compile(`console.log("initialized"); export function run(){return 42;}`, Options{
|
||||
Log: func(string) { initialized.Add(1) },
|
||||
})
|
||||
require.NoError(t, err)
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
value, err := engine.Call(t.Context(), "run")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(42), value)
|
||||
assert.Equal(t, int64(1), initialized.Load())
|
||||
}
|
||||
|
||||
func TestEngineBoundsConcurrentCallsAndCancelsAnOccupiedRuntime(t *testing.T) {
|
||||
entered := make(chan struct{}, 2)
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
engine, err := Compile(`export function run(loop) { utils.unixNow(); if (loop) { while (true) {} } return 42; }`, Options{
|
||||
Concurrency: 2,
|
||||
Now: func() time.Time {
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
return time.Unix(1, 0)
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
canceled := make(chan error, 1)
|
||||
healthy := make(chan error, 1)
|
||||
go func() { _, err := engine.Call(ctx, "run", true); canceled <- err }()
|
||||
go func() { _, err := engine.Call(t.Context(), "run", false); healthy <- err }()
|
||||
<-entered
|
||||
<-entered
|
||||
_, err = engine.CallPathWithAdmissionTimeout(t.Context(), time.Nanosecond, "run", nil, false)
|
||||
require.ErrorIs(t, err, ErrCallAdmissionTimeout)
|
||||
cancel()
|
||||
release <- struct{}{}
|
||||
release <- struct{}{}
|
||||
require.ErrorIs(t, <-canceled, context.Canceled)
|
||||
require.NoError(t, <-healthy)
|
||||
}
|
||||
|
||||
func TestEnginePreservesMutableExportAndMemberBindings(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export let run = function(){return 1;};
|
||||
export const native = {render:function(){return 1;}};
|
||||
export function replace(){run=function(){return 2;};native.render=function(){return 3;};}
|
||||
`, Options{Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
value, err := engine.Call(t.Context(), "run")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), value)
|
||||
_, err = engine.Call(t.Context(), "replace")
|
||||
require.NoError(t, err)
|
||||
value, err = engine.Call(t.Context(), "run")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), value)
|
||||
value, err = engine.CallMember(t.Context(), "native", "render")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), value)
|
||||
}
|
||||
|
||||
func TestEngineHookErrorExtractsSanitizedJSMessage(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/grafana/sobek"
|
||||
)
|
||||
|
||||
const (
|
||||
CapabilityJSONClone = "json-clone@1"
|
||||
CapabilitySubmitSSEDelta = "submit-sse-delta@1"
|
||||
MaxJSONToolBytes = 1 << 20
|
||||
maxJSONToolDepth = 32
|
||||
maxJSONToolNodes = 32768
|
||||
maxJSONChanges = 256
|
||||
)
|
||||
|
||||
// HasCapability describes host APIs independently of a plugin's mutable globals.
|
||||
func HasCapability(name string) bool {
|
||||
return name == CapabilityJSONClone || name == CapabilitySubmitSSEDelta
|
||||
}
|
||||
|
||||
// JSONState owns a request-local JSON result. Appended strings stay in Go and
|
||||
// are materialized only when the stream finishes, independently of JS runtimes.
|
||||
type JSONState struct {
|
||||
root *jsonStateNode
|
||||
limit int
|
||||
failed error
|
||||
}
|
||||
|
||||
type jsonStateNode struct {
|
||||
scalar any
|
||||
object map[string]*jsonStateNode
|
||||
array []*jsonStateNode
|
||||
text *strings.Builder
|
||||
bytes int
|
||||
nodes int
|
||||
}
|
||||
|
||||
type jsonStateBudget struct {
|
||||
ctx context.Context
|
||||
bytes int
|
||||
nodes int
|
||||
}
|
||||
|
||||
func (b *jsonStateBudget) spend(bytes, nodes int) error {
|
||||
if err := b.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if bytes > b.bytes || nodes > b.nodes {
|
||||
return fmt.Errorf("JSON state exceeds size or node limit")
|
||||
}
|
||||
b.bytes -= bytes
|
||||
b.nodes -= nodes
|
||||
return nil
|
||||
}
|
||||
|
||||
func newJSONStateNode(value any, depth int, budget *jsonStateBudget) (*jsonStateNode, error) {
|
||||
if depth > maxJSONToolDepth {
|
||||
return nil, fmt.Errorf("JSON state exceeds depth limit")
|
||||
}
|
||||
if js, ok := value.(sobek.Value); ok {
|
||||
if sobek.IsUndefined(js) {
|
||||
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 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)
|
||||
}
|
||||
value = fields
|
||||
case "Array":
|
||||
length := object.Get("length").ToInteger()
|
||||
if length < 0 || 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 {
|
||||
return nil, fmt.Errorf("sparse arrays are not JSON values")
|
||||
}
|
||||
items[index] = item
|
||||
}
|
||||
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
|
||||
if err := budget.spend(0, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if typed == nil {
|
||||
value = nil
|
||||
}
|
||||
case []any:
|
||||
if typed == nil {
|
||||
value = nil
|
||||
}
|
||||
}
|
||||
node := &jsonStateNode{}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if len(typed) > budget.nodes {
|
||||
return nil, fmt.Errorf("JSON object exceeds node limit")
|
||||
}
|
||||
if err := budget.spend(2, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node.object = make(map[string]*jsonStateNode, len(typed))
|
||||
for key, child := range typed {
|
||||
if !utf8.ValidString(key) || len(key) > budget.bytes {
|
||||
return nil, fmt.Errorf("invalid or oversized JSON object key")
|
||||
}
|
||||
encoded, err := common.Marshal(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cost := len(encoded) + 1
|
||||
if len(node.object) != 0 {
|
||||
cost++
|
||||
}
|
||||
if err = budget.spend(cost, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node.object[key], err = newJSONStateNode(child, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
if len(typed) > budget.nodes {
|
||||
return nil, fmt.Errorf("JSON array exceeds node limit")
|
||||
}
|
||||
if err := budget.spend(2+max(0, len(typed)-1), 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node.array = make([]*jsonStateNode, 0, len(typed))
|
||||
for _, child := range typed {
|
||||
item, err := newJSONStateNode(child, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node.array = append(node.array, item)
|
||||
}
|
||||
default:
|
||||
switch number := value.(type) {
|
||||
case int:
|
||||
value = float64(number)
|
||||
case int64:
|
||||
value = float64(number)
|
||||
}
|
||||
switch scalar := value.(type) {
|
||||
case nil, bool:
|
||||
case float64:
|
||||
if math.IsNaN(scalar) || math.IsInf(scalar, 0) {
|
||||
return nil, fmt.Errorf("JSON numbers must be finite")
|
||||
}
|
||||
case string:
|
||||
if len(scalar) > budget.bytes {
|
||||
return nil, fmt.Errorf("JSON string exceeds size limit")
|
||||
}
|
||||
if !utf8.ValidString(scalar) {
|
||||
value = string([]rune(scalar))
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("value must contain only JSON objects, arrays and scalars")
|
||||
}
|
||||
encoded, err := common.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = budget.spend(len(encoded), 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node.scalar = value
|
||||
}
|
||||
node.bytes, node.nodes = beforeBytes-budget.bytes, beforeNodes-budget.nodes
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func NewJSONState(limit int) *JSONState {
|
||||
return &JSONState{root: &jsonStateNode{bytes: 4, nodes: 1}, limit: max(0, min(limit, MaxJSONToolBytes))}
|
||||
}
|
||||
|
||||
// Apply accepts a bounded batch of set, append and appendText operations.
|
||||
// Failure invalidates the stream: callers must discard the state rather than
|
||||
// continue after an error. No partially updated result may be published.
|
||||
func (s *JSONState) Apply(ctx context.Context, changes any) (err error) {
|
||||
if s.failed != nil {
|
||||
return s.failed
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.failed = err
|
||||
}
|
||||
}()
|
||||
if err = ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
items, ok := changes.([]any)
|
||||
if !ok || len(items) > maxJSONChanges {
|
||||
return fmt.Errorf("changes must be an array of at most %d operations", maxJSONChanges)
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
change, ok := item.(map[string]any)
|
||||
if !ok || len(change) != 3 {
|
||||
return fmt.Errorf("each change must contain only op, path and value")
|
||||
}
|
||||
op, _ := change["op"].(string)
|
||||
path, ok := change["path"].([]any)
|
||||
value, hasValue := change["value"]
|
||||
if !ok || !hasValue || len(path) > maxJSONToolDepth {
|
||||
return fmt.Errorf("change requires a JSON value and a path of at most %d segments", maxJSONToolDepth)
|
||||
}
|
||||
if op != "set" && op != "append" && op != "appendText" {
|
||||
return fmt.Errorf("unsupported JSON change operation %q", op)
|
||||
}
|
||||
ancestors := make([]*jsonStateNode, 0, len(path))
|
||||
target := s.root
|
||||
var key string
|
||||
var index int
|
||||
for offset, segment := range path {
|
||||
if target == nil {
|
||||
return fmt.Errorf("JSON change parent does not exist")
|
||||
}
|
||||
ancestors = append(ancestors, target)
|
||||
switch {
|
||||
case target.object != nil:
|
||||
key, ok = segment.(string)
|
||||
if !ok || !utf8.ValidString(key) || len(key) > s.limit {
|
||||
return fmt.Errorf("JSON object paths require valid string keys")
|
||||
}
|
||||
target = target.object[key]
|
||||
case target.array != nil:
|
||||
var number float64
|
||||
switch typed := segment.(type) {
|
||||
case int:
|
||||
number = float64(typed)
|
||||
case int64:
|
||||
number = float64(typed)
|
||||
case float64:
|
||||
number = typed
|
||||
default:
|
||||
return fmt.Errorf("JSON array paths require integer indices")
|
||||
}
|
||||
if math.IsNaN(number) || number < 0 || number >= float64(len(target.array)) || math.Trunc(number) != number {
|
||||
return fmt.Errorf("JSON array index is out of range")
|
||||
}
|
||||
index = int(number)
|
||||
target = target.array[index]
|
||||
default:
|
||||
return fmt.Errorf("JSON change path traverses a scalar")
|
||||
}
|
||||
if target == nil && (offset != len(path)-1 || op != "set") {
|
||||
return fmt.Errorf("JSON change target does not exist")
|
||||
}
|
||||
}
|
||||
addedBytes, addedNodes := 0, 0
|
||||
switch op {
|
||||
case "set":
|
||||
oldBytes, oldNodes := 0, 0
|
||||
if target != nil {
|
||||
oldBytes, oldNodes = target.bytes, target.nodes
|
||||
} else {
|
||||
encodedKey, err := common.Marshal(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addedBytes = len(encodedKey) + 1
|
||||
if len(ancestors[len(ancestors)-1].object) != 0 {
|
||||
addedBytes++
|
||||
}
|
||||
}
|
||||
budget := &jsonStateBudget{ctx: ctx, bytes: s.limit - s.root.bytes + oldBytes - addedBytes, nodes: maxJSONToolNodes - s.root.nodes + oldNodes}
|
||||
next, err := newJSONStateNode(value, len(path), budget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addedBytes += next.bytes - oldBytes
|
||||
addedNodes = next.nodes - oldNodes
|
||||
if len(ancestors) == 0 {
|
||||
s.root = next
|
||||
} else if parent := ancestors[len(ancestors)-1]; parent.object != nil {
|
||||
parent.object[key] = next
|
||||
} else {
|
||||
parent.array[index] = next
|
||||
}
|
||||
case "append":
|
||||
if target.array == nil {
|
||||
return fmt.Errorf("append requires an array target")
|
||||
}
|
||||
if len(target.array) != 0 {
|
||||
addedBytes = 1
|
||||
}
|
||||
budget := &jsonStateBudget{ctx: ctx, bytes: s.limit - s.root.bytes - addedBytes, nodes: maxJSONToolNodes - s.root.nodes}
|
||||
next, err := newJSONStateNode(value, len(path)+1, budget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addedBytes += next.bytes
|
||||
addedNodes = next.nodes
|
||||
target.array = append(target.array, next)
|
||||
target.bytes += addedBytes
|
||||
target.nodes += addedNodes
|
||||
case "appendText":
|
||||
previous, isText := target.scalar.(string)
|
||||
text, ok := value.(string)
|
||||
if !isText || !ok || len(text) > s.limit-s.root.bytes {
|
||||
return fmt.Errorf("appendText requires strings within the state size limit")
|
||||
}
|
||||
if !utf8.ValidString(text) {
|
||||
text = string([]rune(text))
|
||||
}
|
||||
encoded, err := common.Marshal(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addedBytes = len(encoded) - 2
|
||||
if addedBytes > s.limit-s.root.bytes {
|
||||
return fmt.Errorf("JSON state exceeds size limit")
|
||||
}
|
||||
if text != "" {
|
||||
if target.text == nil {
|
||||
target.text = &strings.Builder{}
|
||||
target.text.WriteString(previous)
|
||||
target.scalar = ""
|
||||
}
|
||||
target.text.WriteString(text)
|
||||
}
|
||||
target.bytes += addedBytes
|
||||
}
|
||||
for _, ancestor := range ancestors {
|
||||
ancestor.bytes += addedBytes
|
||||
ancestor.nodes += addedNodes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value returns the final ordinary JSON tree and verifies the incremental byte
|
||||
// accounting against the configured codec before callers can persist it.
|
||||
func (s *JSONState) Value() (any, error) {
|
||||
if s.failed != nil {
|
||||
return nil, s.failed
|
||||
}
|
||||
value := s.root.value()
|
||||
encoded, err := common.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(encoded) != s.root.bytes || len(encoded) > s.limit {
|
||||
return nil, fmt.Errorf("JSON state encoded size does not match its bounded representation")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (n *jsonStateNode) value() any {
|
||||
if n.object != nil {
|
||||
result := make(map[string]any, len(n.object))
|
||||
for key, child := range n.object {
|
||||
result[key] = child.value()
|
||||
}
|
||||
return result
|
||||
}
|
||||
if n.array != nil {
|
||||
result := make([]any, len(n.array))
|
||||
for index, child := range n.array {
|
||||
result[index] = child.value()
|
||||
}
|
||||
return result
|
||||
}
|
||||
if n.text != nil {
|
||||
return n.text.String()
|
||||
}
|
||||
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) {
|
||||
if n.object != nil {
|
||||
result := runtime.NewObject()
|
||||
for key, child := range n.object {
|
||||
value, err := child.jsValue(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = result.DefineDataProperty(key, value, sobek.FLAG_TRUE, sobek.FLAG_TRUE, sobek.FLAG_TRUE); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if n.array != nil {
|
||||
items := make([]any, len(n.array))
|
||||
for index, child := range n.array {
|
||||
value, err := child.jsValue(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[index] = value
|
||||
}
|
||||
return runtime.NewArray(items...), nil
|
||||
}
|
||||
return runtime.ToValue(n.value()), nil
|
||||
}
|
||||
+232
-50
@@ -40,6 +40,11 @@ var pluginKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
var pluginVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
|
||||
var localeTagPattern = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$`)
|
||||
|
||||
// ValidPluginKey checks the canonical identifier accepted by plugin manifests.
|
||||
func ValidPluginKey(key string) bool {
|
||||
return len(key) <= 30 && pluginKeyPattern.MatchString(key)
|
||||
}
|
||||
|
||||
// LocalizedText is locale-keyed display copy. Plugin source may use a bare
|
||||
// string (normalized to {"en": s}) or a map that must include "en". API
|
||||
// responses always emit an object.
|
||||
@@ -79,25 +84,50 @@ func (t *LocalizedText) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
SortPriority int `json:"sortPriority,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
APIVersion int `json:"apiVersion"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Description LocalizedText `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author AuthorMeta `json:"author"`
|
||||
BaseURL string `json:"baseUrl,omitempty"`
|
||||
ChannelTypes []int `json:"channelTypes,omitempty"`
|
||||
Models []string `json:"models"`
|
||||
FetchMode string `json:"fetchMode"`
|
||||
AllowedHosts []string `json:"allowedHosts"`
|
||||
Routes []Route `json:"routes"`
|
||||
Protocols []ProtocolClaim `json:"protocols"`
|
||||
UsageSchema map[string]UsageFieldSchema `json:"usageSchema,omitempty"`
|
||||
UsageExamples []UsageExample `json:"usageExamples,omitempty"`
|
||||
Auth AuthMeta `json:"auth"`
|
||||
RequiredCapabilities []string `json:"requiredCapabilities,omitempty"`
|
||||
SubmitResponseTypes []string `json:"submitResponseTypes,omitempty"`
|
||||
SortPriority int `json:"sortPriority,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
APIVersion int `json:"apiVersion"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Description LocalizedText `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author AuthorMeta `json:"author"`
|
||||
BaseURL string `json:"baseUrl,omitempty"`
|
||||
ChannelTypes []int `json:"channelTypes,omitempty"`
|
||||
Models []string `json:"models"`
|
||||
FetchMode string `json:"fetchMode"`
|
||||
AllowedHosts []string `json:"allowedHosts"`
|
||||
Routes []Route `json:"routes"`
|
||||
Protocols []ProtocolClaim `json:"protocols"`
|
||||
UsageSchema map[string]UsageFieldSchema `json:"usageSchema,omitempty"`
|
||||
UsageExamples []UsageExample `json:"usageExamples,omitempty"`
|
||||
UsageProfiles []UsageProfile `json:"usageProfiles,omitempty"`
|
||||
Auth AuthMeta `json:"auth"`
|
||||
}
|
||||
|
||||
// UsageProfile replaces the plugin's default usage metadata for its models.
|
||||
type UsageProfile struct {
|
||||
Models []string `json:"models"`
|
||||
Schema map[string]UsageFieldSchema `json:"schema"`
|
||||
Examples []UsageExample `json:"examples,omitempty"`
|
||||
}
|
||||
|
||||
// UsageForModel returns read-only usage metadata for a declared model. Aliases
|
||||
// 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.UsageSchema, m.UsageExamples
|
||||
}
|
||||
|
||||
// ProtocolSupports reports whether the named protocol claim includes mode.
|
||||
@@ -132,6 +162,7 @@ type AuthMeta struct {
|
||||
type UsageFieldSchema struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
UnitLabel LocalizedText `json:"unitLabel,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
Description LocalizedText `json:"description,omitempty"`
|
||||
EnumLabels map[string]LocalizedText `json:"enumLabels,omitempty"`
|
||||
@@ -264,6 +295,13 @@ func CompilePlugin(source string, options Options) (*LoadedPlugin, error) {
|
||||
engine.key = meta.Key
|
||||
engine.version = meta.Version
|
||||
requiredHooks := []string{"buildSubmitRequest", "parseSubmitResponse", "parseTaskResult"}
|
||||
if slices.Contains(meta.SubmitResponseTypes, "sse") {
|
||||
if slices.Contains(meta.RequiredCapabilities, CapabilitySubmitSSEDelta) {
|
||||
requiredHooks = append(requiredHooks, "parseSubmitEventDelta")
|
||||
} else {
|
||||
requiredHooks = append(requiredHooks, "parseSubmitEvent")
|
||||
}
|
||||
}
|
||||
if meta.FetchMode == "batch" {
|
||||
requiredHooks = append(requiredHooks, "buildBatchQueryRequest", "parseBatchResult")
|
||||
} else {
|
||||
@@ -791,6 +829,8 @@ func (r *Registry) Snapshot() RegistrySnapshot {
|
||||
}
|
||||
|
||||
func cloneMeta(meta Meta) Meta {
|
||||
meta.SubmitResponseTypes = slices.Clone(meta.SubmitResponseTypes)
|
||||
meta.RequiredCapabilities = slices.Clone(meta.RequiredCapabilities)
|
||||
meta.ChannelTypes = append([]int(nil), meta.ChannelTypes...)
|
||||
meta.Models = append([]string(nil), meta.Models...)
|
||||
meta.AllowedHosts = append([]string(nil), meta.AllowedHosts...)
|
||||
@@ -806,31 +846,46 @@ func cloneMeta(meta Meta) Meta {
|
||||
if meta.Description != nil {
|
||||
meta.Description = maps.Clone(meta.Description)
|
||||
}
|
||||
if meta.UsageSchema != nil {
|
||||
usageSchema := make(map[string]UsageFieldSchema, len(meta.UsageSchema))
|
||||
for key, field := range meta.UsageSchema {
|
||||
if field.Enum != nil {
|
||||
field.Enum = append([]string{}, field.Enum...)
|
||||
}
|
||||
if field.Description != nil {
|
||||
field.Description = maps.Clone(field.Description)
|
||||
}
|
||||
if field.EnumLabels != nil {
|
||||
labels := make(map[string]LocalizedText, len(field.EnumLabels))
|
||||
for value, label := range field.EnumLabels {
|
||||
labels[value] = maps.Clone(label)
|
||||
}
|
||||
field.EnumLabels = labels
|
||||
}
|
||||
usageSchema[key] = field
|
||||
}
|
||||
meta.UsageSchema = usageSchema
|
||||
meta.UsageSchema = CloneUsageSchema(meta.UsageSchema)
|
||||
meta.UsageExamples = CloneUsageExamples(meta.UsageExamples)
|
||||
meta.UsageProfiles = append([]UsageProfile(nil), meta.UsageProfiles...)
|
||||
for index := range meta.UsageProfiles {
|
||||
profile := &meta.UsageProfiles[index]
|
||||
profile.Models = append([]string(nil), profile.Models...)
|
||||
profile.Schema = CloneUsageSchema(profile.Schema)
|
||||
profile.Examples = CloneUsageExamples(profile.Examples)
|
||||
}
|
||||
meta.UsageExamples = cloneUsageExamples(meta.UsageExamples)
|
||||
return meta
|
||||
}
|
||||
|
||||
func cloneUsageExamples(examples []UsageExample) []UsageExample {
|
||||
// CloneUsageSchema copies schema fields and localized metadata for independent readers.
|
||||
func CloneUsageSchema(schema map[string]UsageFieldSchema) map[string]UsageFieldSchema {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]UsageFieldSchema, len(schema))
|
||||
for key, field := range schema {
|
||||
if field.Enum != nil {
|
||||
field.Enum = append([]string{}, field.Enum...)
|
||||
}
|
||||
if field.Description != nil {
|
||||
field.Description = maps.Clone(field.Description)
|
||||
}
|
||||
field.UnitLabel = maps.Clone(field.UnitLabel)
|
||||
if field.EnumLabels != nil {
|
||||
labels := make(map[string]LocalizedText, len(field.EnumLabels))
|
||||
for value, label := range field.EnumLabels {
|
||||
labels[value] = maps.Clone(label)
|
||||
}
|
||||
field.EnumLabels = labels
|
||||
}
|
||||
cloned[key] = field
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// CloneUsageExamples copies example facts for independent readers.
|
||||
func CloneUsageExamples(examples []UsageExample) []UsageExample {
|
||||
if examples == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -904,6 +959,15 @@ func (r *Registry) resolveActiveOverrides(
|
||||
return active
|
||||
}
|
||||
|
||||
// UnknownMetaFieldError identifies a manifest field unsupported by this host.
|
||||
type UnknownMetaFieldError struct {
|
||||
Field string
|
||||
}
|
||||
|
||||
func (e *UnknownMetaFieldError) Error() string {
|
||||
return fmt.Sprintf("plugin meta has unknown field %q", e.Field)
|
||||
}
|
||||
|
||||
func decodeMeta(value any) (Meta, error) {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
@@ -911,9 +975,9 @@ func decodeMeta(value any) (Meta, error) {
|
||||
}
|
||||
for field := range object {
|
||||
switch field {
|
||||
case "sortPriority", "website", "apiVersion", "key", "name", "icon", "description", "version", "author", "baseUrl", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "routes", "protocols", "usageSchema", "usageExamples", "auth", "endpoints", "submitPaths", "actions":
|
||||
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":
|
||||
default:
|
||||
return Meta{}, fmt.Errorf("plugin meta has unknown field %q", field)
|
||||
return Meta{}, &UnknownMetaFieldError{Field: field}
|
||||
}
|
||||
}
|
||||
meta := Meta{}
|
||||
@@ -978,6 +1042,27 @@ func decodeMeta(value any) (Meta, error) {
|
||||
if meta.FetchMode, err = stringMetaField(object, "fetchMode"); err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
meta.SubmitResponseTypes = []string{"json"}
|
||||
if _, present := object["submitResponseTypes"]; present {
|
||||
meta.SubmitResponseTypes, err = strictStringSlice(object, "submitResponseTypes")
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
if len(meta.SubmitResponseTypes) == 0 {
|
||||
return Meta{}, fmt.Errorf("submitResponseTypes must not be empty")
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, kind := range meta.SubmitResponseTypes {
|
||||
if (kind != "json" && kind != "sse") || seen[kind] {
|
||||
return Meta{}, fmt.Errorf("invalid or duplicate submitResponseTypes value %q", kind)
|
||||
}
|
||||
seen[kind] = true
|
||||
}
|
||||
}
|
||||
meta.RequiredCapabilities, err = strictStringSlice(object, "requiredCapabilities")
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
meta.Models, err = strictStringSlice(object, "models")
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
@@ -1009,6 +1094,12 @@ func decodeMeta(value any) (Meta, error) {
|
||||
return Meta{}, err
|
||||
}
|
||||
}
|
||||
if usageProfiles, exists := object["usageProfiles"]; exists {
|
||||
meta.UsageProfiles, err = decodeUsageProfiles(usageProfiles)
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
}
|
||||
for _, removedField := range []string{"submitPaths", "actions"} {
|
||||
if _, exists := object[removedField]; exists {
|
||||
return Meta{}, fmt.Errorf("plugin meta %s is no longer supported; declare routes instead", removedField)
|
||||
@@ -1058,6 +1149,16 @@ func ValidateV1Meta(meta Meta) error {
|
||||
}
|
||||
|
||||
func normalizeV1Meta(meta *Meta) error {
|
||||
seenCapabilities := make(map[string]bool, len(meta.RequiredCapabilities))
|
||||
for _, name := range meta.RequiredCapabilities {
|
||||
if !HasCapability(name) || seenCapabilities[name] {
|
||||
return fmt.Errorf("unsupported or duplicate required capability %q", name)
|
||||
}
|
||||
if name == CapabilitySubmitSSEDelta && !slices.Contains(meta.SubmitResponseTypes, "sse") {
|
||||
return fmt.Errorf("%s requires submitResponseTypes to include sse", name)
|
||||
}
|
||||
seenCapabilities[name] = true
|
||||
}
|
||||
if meta.SortPriority < math.MinInt32 || meta.SortPriority > math.MaxInt32 {
|
||||
return fmt.Errorf("plugin meta sortPriority must be a signed 32-bit integer")
|
||||
}
|
||||
@@ -1132,12 +1233,12 @@ func normalizeV1Meta(meta *Meta) error {
|
||||
}
|
||||
meta.BaseURL = normalized
|
||||
}
|
||||
if !pluginKeyPattern.MatchString(meta.Key) {
|
||||
return fmt.Errorf("plugin meta key must match %s", pluginKeyPattern)
|
||||
}
|
||||
if len(meta.Key) > 30 {
|
||||
return fmt.Errorf("plugin meta key must not exceed 30 characters")
|
||||
}
|
||||
if !ValidPluginKey(meta.Key) {
|
||||
return fmt.Errorf("plugin meta key must match %s", pluginKeyPattern)
|
||||
}
|
||||
if !pluginVersionPattern.MatchString(meta.Version) {
|
||||
return fmt.Errorf("plugin meta version must be semver")
|
||||
}
|
||||
@@ -1258,7 +1359,44 @@ func normalizeV1Meta(meta *Meta) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, field := range meta.UsageSchema {
|
||||
if err := validateUsageSchema(meta.UsageSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateUsageExamples(meta.UsageSchema, meta.UsageExamples); err != nil {
|
||||
return err
|
||||
}
|
||||
profileModels := make(map[string]struct{})
|
||||
for index, profile := range meta.UsageProfiles {
|
||||
if len(profile.Models) == 0 {
|
||||
return fmt.Errorf("plugin meta usageProfiles[%d] models must contain at least one model", index)
|
||||
}
|
||||
if err := validateModelScope(profile.Models, fmt.Sprintf("usageProfiles[%d]", index)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, model := range profile.Models {
|
||||
if _, exists := models[model]; !exists {
|
||||
return fmt.Errorf("plugin meta usageProfiles[%d] model %q is not declared in plugin meta models", index, model)
|
||||
}
|
||||
if _, duplicate := profileModels[model]; duplicate {
|
||||
return fmt.Errorf("plugin meta usageProfiles model %q belongs to multiple profiles", model)
|
||||
}
|
||||
profileModels[model] = struct{}{}
|
||||
}
|
||||
if profile.Schema == nil {
|
||||
return fmt.Errorf("plugin meta usageProfiles[%d] schema must be an object", index)
|
||||
}
|
||||
if err := validateUsageSchema(profile.Schema); err != nil {
|
||||
return fmt.Errorf("plugin meta usageProfiles[%d]: %w", index, err)
|
||||
}
|
||||
if err := validateUsageExamples(profile.Schema, profile.Examples); err != nil {
|
||||
return fmt.Errorf("plugin meta usageProfiles[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUsageSchema(schema map[string]UsageFieldSchema) error {
|
||||
for name, field := range schema {
|
||||
if strings.TrimSpace(name) == "" || strings.TrimSpace(name) != name {
|
||||
return fmt.Errorf("plugin meta usageSchema keys must be non-empty canonical names")
|
||||
}
|
||||
@@ -1266,12 +1404,45 @@ func normalizeV1Meta(meta *Meta) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateUsageExamples(meta.UsageSchema, meta.UsageExamples); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeUsageProfiles(value any) ([]UsageProfile, error) {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin meta usageProfiles must be an array")
|
||||
}
|
||||
profiles := make([]UsageProfile, 0, len(items))
|
||||
for index, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin meta usageProfiles[%d] must be an object", index)
|
||||
}
|
||||
for key := range object {
|
||||
if key != "models" && key != "schema" && key != "examples" {
|
||||
return nil, fmt.Errorf("plugin meta usageProfiles[%d] has unknown field %q", index, key)
|
||||
}
|
||||
}
|
||||
models, err := strictStringSlice(object, "models")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin meta usageProfiles[%d]: %w", index, err)
|
||||
}
|
||||
schema, err := decodeUsageSchema(object["schema"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin meta usageProfiles[%d]: %w", index, err)
|
||||
}
|
||||
profile := UsageProfile{Models: models, Schema: schema}
|
||||
if examples, exists := object["examples"]; exists {
|
||||
profile.Examples, err = decodeUsageExamples(examples)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin meta usageProfiles[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) {
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("plugin meta usageSchema must be an object")
|
||||
@@ -1288,7 +1459,7 @@ func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) {
|
||||
}
|
||||
for key := range fieldObject {
|
||||
switch key {
|
||||
case "type", "unit", "enum", "description", "enumLabels":
|
||||
case "type", "unit", "unitLabel", "enum", "description", "enumLabels":
|
||||
default:
|
||||
return nil, fmt.Errorf("plugin meta usageSchema field %q has unknown property %q", name, key)
|
||||
}
|
||||
@@ -1301,6 +1472,9 @@ func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) {
|
||||
if field.Unit, err = stringMetaField(fieldObject, "unit"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if field.UnitLabel, err = localizedTextMetaField(fieldObject, "unitLabel", maxUsageFieldDescriptionRunes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if field.Description, err = localizedTextMetaField(fieldObject, "description", maxUsageFieldDescriptionRunes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1332,6 +1506,14 @@ func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) {
|
||||
}
|
||||
|
||||
func validateUsageFieldSchema(name string, field UsageFieldSchema) error {
|
||||
if field.UnitLabel != nil {
|
||||
if field.Type != "number" || field.Unit != "count" || field.Enum != nil {
|
||||
return fmt.Errorf("plugin meta usageSchema field %q unitLabel requires a number field with count unit", name)
|
||||
}
|
||||
if err := validateLocalizedText(field.UnitLabel, fmt.Sprintf("usageSchema field %q unitLabel", name), maxUsageFieldDescriptionRunes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateLocalizedText(field.Description, fmt.Sprintf("usageSchema field %q description", name), maxUsageFieldDescriptionRunes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -327,6 +327,44 @@ func TestRegistryDecodesAndValidatesUsageSchema(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsageUnitLabelContract(t *testing.T) {
|
||||
source := routingTestPluginSource("unit-label", 0, `["model"]`, `usageSchema: {
|
||||
images: {type: "number", unit: "count", unitLabel: {EN: " image ", zh: "张"}},
|
||||
items: {type: "number", unit: "count", unitLabel: "item"},
|
||||
legacy: {type: "number", unit: "count"}
|
||||
},`, "")
|
||||
registry := NewRegistry()
|
||||
plugin, err := registry.Register(source, Options{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, LocalizedText{"en": "image", "zh": "张"}, plugin.Meta.UsageSchema["images"].UnitLabel)
|
||||
assert.Equal(t, LocalizedText{"en": "item"}, plugin.Meta.UsageSchema["items"].UnitLabel)
|
||||
assert.Nil(t, plugin.Meta.UsageSchema["legacy"].UnitLabel)
|
||||
wire, err := common.Marshal(plugin.Meta.UsageSchema)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"images":{"type":"number","unit":"count","unitLabel":{"en":"image","zh":"张"}},"items":{"type":"number","unit":"count","unitLabel":{"en":"item"}},"legacy":{"type":"number","unit":"count"}}`, string(wire))
|
||||
snapshot := registry.Snapshot()
|
||||
snapshot.Override[0].UsageSchema["images"].UnitLabel["en"] = "Changed"
|
||||
assert.Equal(t, "image", registry.Snapshot().Override[0].UsageSchema["images"].UnitLabel["en"])
|
||||
|
||||
for _, tc := range []struct{ name, field, message string }{
|
||||
{"seconds", `{type:"number",unit:"second",unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
||||
{"tokens", `{type:"number",unit:"token",unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
||||
{"credits", `{type:"number",unit:"credit",unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
||||
{"boolean", `{type:"boolean",unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
||||
{"enum", `{enum:["a"],unitLabel:"image"}`, "unitLabel requires a number field with count unit"},
|
||||
{"missing English", `{type:"number",unit:"count",unitLabel:{zh:"张"}}`, `must include a non-empty "en"`},
|
||||
{"null", `{type:"number",unit:"count",unitLabel:null}`, "must be a string or object"},
|
||||
{"empty", `{type:"number",unit:"count",unitLabel:" "}`, "non-empty string"},
|
||||
{"invalid locale", `{type:"number",unit:"count",unitLabel:{en:"image",zh_CN:"张"}}`, "invalid locale"},
|
||||
{"too long", `{type:"number",unit:"count",unitLabel:"` + strings.Repeat("x", 257) + `"}`, "must not exceed 256 characters"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := CompilePlugin(routingTestPluginSource("invalid-unit-label", 0, `["model"]`, "usageSchema: {value: "+tc.field+"},", ""), Options{})
|
||||
require.ErrorContains(t, err, tc.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageEnumLabelsContract(t *testing.T) {
|
||||
source := routingTestPluginSource("enum-labels", 0, `["model"]`, `usageSchema: {
|
||||
mode: {enum: ["none", "video", "other"], enumLabels: {none: "No video", video: {EN: " With video ", zh: "有参考视频"}}}
|
||||
@@ -1058,3 +1096,136 @@ func TestPluginDisplayMetadata(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryUsageProfilesReplaceDefaultsAndIsolateSnapshots(t *testing.T) {
|
||||
source := routingTestPluginSource("usage-profiles", 0, `["image", "video", "default", "empty"]`, `
|
||||
usageSchema: {seconds: {type: "number", unit: "second"}},
|
||||
usageExamples: [{label: "default", facts: {seconds: 1}}],
|
||||
usageProfiles: [
|
||||
{models: ["image"], schema: {image_count: {type: "number", unit: "count", unitLabel: {en: "image", zh: "张"}}}},
|
||||
{models: ["video"], schema: {
|
||||
seconds: {type: "number", unit: "second", description: {en: "Duration"}},
|
||||
resolution: {enum: ["720P", "1080P"], enumLabels: {"720P": {en: "HD"}}}
|
||||
}, examples: [{label: "HD video", facts: {seconds: 5, resolution: "720P"}}]},
|
||||
{models: ["empty"], schema: {}}
|
||||
],`, "")
|
||||
registry := NewRegistry()
|
||||
plugin, err := registry.Register(source, Options{})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, model := range []string{"image", "IMAGE"} {
|
||||
schema, examples := plugin.Meta.UsageForModel(model)
|
||||
assert.Equal(t, map[string]UsageFieldSchema{"image_count": {Type: "number", Unit: "count", UnitLabel: LocalizedText{"en": "image", "zh": "张"}}}, schema)
|
||||
assert.Nil(t, examples, "profiles must not inherit unrelated default examples")
|
||||
}
|
||||
for _, model := range []string{"default", "unknown", ""} {
|
||||
schema, examples := plugin.Meta.UsageForModel(model)
|
||||
assert.Equal(t, plugin.Meta.UsageSchema, schema)
|
||||
assert.Equal(t, plugin.Meta.UsageExamples, examples)
|
||||
}
|
||||
schema, examples := plugin.Meta.UsageForModel("empty")
|
||||
assert.Empty(t, schema)
|
||||
assert.Nil(t, examples)
|
||||
|
||||
snapshot := registry.Snapshot()
|
||||
require.Len(t, snapshot.Override, 1)
|
||||
profile := &snapshot.Override[0].UsageProfiles[1]
|
||||
snapshot.Override[0].UsageProfiles[0].Schema["image_count"].UnitLabel["en"] = "Changed"
|
||||
imageSchema, _ := registry.Snapshot().Override[0].UsageForModel("image")
|
||||
assert.Equal(t, "image", imageSchema["image_count"].UnitLabel["en"])
|
||||
profile.Models[0] = "changed"
|
||||
profile.Schema["seconds"].Description["en"] = "Changed"
|
||||
profile.Schema["resolution"].Enum[0] = "changed"
|
||||
profile.Schema["resolution"].EnumLabels["720P"]["en"] = "Changed"
|
||||
profile.Examples[0].Label = "Changed"
|
||||
profile.Examples[0].Facts["seconds"] = 99
|
||||
delete(profile.Schema, "seconds")
|
||||
|
||||
schema, examples = registry.Snapshot().Override[0].UsageForModel("video")
|
||||
assert.Equal(t, "Duration", schema["seconds"].Description["en"])
|
||||
assert.Equal(t, []string{"720P", "1080P"}, schema["resolution"].Enum)
|
||||
assert.Equal(t, "HD", schema["resolution"].EnumLabels["720P"]["en"])
|
||||
require.Len(t, examples, 1)
|
||||
assert.Equal(t, "HD video", examples[0].Label)
|
||||
assert.EqualValues(t, 5, examples[0].Facts["seconds"])
|
||||
|
||||
legacy, err := CompilePlugin(routingTestPluginSource("legacy-usage", 0, `["image"]`,
|
||||
`usageSchema: {seconds: {type: "number", unit: "second"}},`, ""), Options{})
|
||||
require.NoError(t, err)
|
||||
schema, examples = legacy.Meta.UsageForModel("image")
|
||||
assert.Equal(t, legacy.Meta.UsageSchema, schema)
|
||||
assert.Nil(t, examples)
|
||||
}
|
||||
|
||||
func TestRegistryRejectsInvalidUsageProfiles(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, profiles, errorText string
|
||||
}{
|
||||
{"null", `null`, "usageProfiles must be an array"},
|
||||
{"object", `{}`, "usageProfiles must be an array"},
|
||||
{"invalid entry", `[null]`, "must be an object"},
|
||||
{"unknown property", `[{models:["image"], schema:{}, extra:true}]`, "unknown field"},
|
||||
{"missing models", `[{schema:{}}]`, "at least one model"},
|
||||
{"empty models", `[{models:[], schema:{}}]`, "at least one model"},
|
||||
{"unknown model", `[{models:["other"], schema:{}}]`, "not declared"},
|
||||
{"noncanonical model", `[{models:[" image"], schema:{}}]`, "canonical names"},
|
||||
{"wrong declared spelling", `[{models:["IMAGE"], schema:{}}]`, "not declared"},
|
||||
{"duplicate models", `[{models:["image","IMAGE"], schema:{}}]`, "unique case-insensitively"},
|
||||
{"overlapping profiles", `[{models:["image"], schema:{}}, {models:["image"], schema:{}}]`, "multiple profiles"},
|
||||
{"missing schema", `[{models:["image"]}]`, "must be an object"},
|
||||
{"invalid field", `[{models:["image"], schema:{n:{type:"number", unit:"invalid"}}}]`, "unit must be"},
|
||||
{"noncanonical field", `[{models:["image"], schema:{" n":{type:"number", unit:"count"}}}]`, "canonical names"},
|
||||
{"tokens need examples", `[{models:["image"], schema:{tokens:{type:"number", unit:"token"}}}]`, "usageExamples is required"},
|
||||
{"examples use own schema", `[{models:["image"], schema:{n:{type:"number", unit:"count"}}, examples:[{label:"wrong", facts:{seconds:1}}]}]`, "usageProfiles[0]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
source := routingTestPluginSource("invalid-profile", 0, `["image", "video"]`,
|
||||
`usageSchema: {seconds: {type:"number", unit:"second"}}, usageExamples: [{label:"default", facts:{seconds:1}}], usageProfiles: `+tc.profiles+`,`, "")
|
||||
_, err := CompilePlugin(source, Options{})
|
||||
require.ErrorContains(t, err, tc.errorText)
|
||||
})
|
||||
}
|
||||
|
||||
plugin, err := CompilePlugin(routingTestPluginSource("token-profile", 0, `["image"]`, `
|
||||
usageProfiles: [{models:["image"], schema:{tokens:{type:"number", unit:"token"}}, examples:[{label:"one token", facts:{tokens:1}}]}],`, ""), Options{})
|
||||
require.NoError(t, err)
|
||||
schema, examples := plugin.Meta.UsageForModel("image")
|
||||
assert.Equal(t, "token", schema["tokens"].Unit)
|
||||
require.Len(t, examples, 1)
|
||||
assert.EqualValues(t, 1, examples[0].Facts["tokens"])
|
||||
plugin.Meta.UsageProfiles[0].Schema = nil
|
||||
require.ErrorContains(t, ValidateV1Meta(plugin.Meta), "schema must be an object")
|
||||
}
|
||||
|
||||
func TestSubmitResponseTypesContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, metadata, hook string
|
||||
valid bool
|
||||
}{
|
||||
{"legacy JSON", "", "", true},
|
||||
{"SSE declared", `submitResponseTypes:["json","sse"],`, `export function parseSubmitEvent(){return {state:null,done:true};}`, true},
|
||||
{"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},
|
||||
{"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},
|
||||
{"null capabilities", `requiredCapabilities:null,`, "", false},
|
||||
{"missing hook", `submitResponseTypes:["sse"],`, "", false},
|
||||
{"unknown type", `submitResponseTypes:["xml"],`, "", false},
|
||||
{"duplicate", `submitResponseTypes:["json","json"],`, "", false},
|
||||
{"empty", `submitResponseTypes:[],`, "", false},
|
||||
{"null", `submitResponseTypes:null,`, "", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
source := routingTestPluginSource("submit-types", 0, `["model"]`, tc.metadata, "") + tc.hook
|
||||
plugin, err := CompilePlugin(source, Options{})
|
||||
if tc.valid {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ValidateV1Meta(plugin.Meta))
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -326,6 +326,7 @@ type RoutingGeneration struct {
|
||||
|
||||
byKey map[string]*LoadedPlugin
|
||||
byModel map[string]*LoadedPlugin
|
||||
modelPlugins map[string][]*LoadedPlugin
|
||||
canonicalModelByFold map[string]string
|
||||
byChannelType map[int]*LoadedPlugin
|
||||
routeIndex map[string]RouteBinding
|
||||
@@ -416,6 +417,20 @@ func (g *RoutingGeneration) GetByModel(model string) (*LoadedPlugin, bool) {
|
||||
return plugin, ok
|
||||
}
|
||||
|
||||
// PluginsByModel returns all plugins declaring model, in ascending key order.
|
||||
func (g *RoutingGeneration) PluginsByModel(model string) []*LoadedPlugin {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return slices.Clone(g.modelPlugins[model])
|
||||
}
|
||||
|
||||
// SharedModel reports whether multiple plugins declare a model without copying
|
||||
// its provider list on the relay hot path.
|
||||
func (g *RoutingGeneration) SharedModel(model string) bool {
|
||||
return g != nil && len(g.modelPlugins[model]) >= 2
|
||||
}
|
||||
|
||||
// CanonicalModel returns the declared spelling for model. An exact byModel
|
||||
// hit wins and returns the input unchanged; otherwise the ASCII-folded
|
||||
// index is consulted. Miss and nil-receiver return ("", false).
|
||||
@@ -463,9 +478,8 @@ func (g *RoutingGeneration) LookupEndpoint(method, path, model string) (Protocol
|
||||
return bindings[0], true
|
||||
}
|
||||
|
||||
// LookupEndpointCandidates returns every legacy provider implementation that
|
||||
// can serve one shared model endpoint. Candidate order is deterministic and
|
||||
// the first binding is the parser used before channel distribution.
|
||||
// LookupEndpointCandidates returns every plugin that can serve a shared model
|
||||
// endpoint, in ascending key order. Each candidate decodes before distribution.
|
||||
func (g *RoutingGeneration) LookupEndpointCandidates(method, path, model string) []ProtocolBinding {
|
||||
if g == nil {
|
||||
return nil
|
||||
@@ -859,6 +873,7 @@ func buildRoutingGenerationFromPlugins(effective map[string]*LoadedPlugin, numbe
|
||||
PublishedAt: time.Now(),
|
||||
byKey: make(map[string]*LoadedPlugin, len(effective)),
|
||||
byModel: make(map[string]*LoadedPlugin),
|
||||
modelPlugins: make(map[string][]*LoadedPlugin),
|
||||
canonicalModelByFold: make(map[string]string),
|
||||
byChannelType: make(map[int]*LoadedPlugin),
|
||||
routeIndex: make(map[string]RouteBinding),
|
||||
@@ -870,6 +885,7 @@ func buildRoutingGenerationFromPlugins(effective map[string]*LoadedPlugin, numbe
|
||||
generation.byKey[key] = plugin
|
||||
generation.plugins = append(generation.plugins, plugin)
|
||||
for _, model := range plugin.Meta.Models {
|
||||
generation.modelPlugins[model] = append(generation.modelPlugins[model], plugin)
|
||||
if _, exists := generation.byModel[model]; !exists {
|
||||
generation.byModel[model] = plugin
|
||||
}
|
||||
@@ -927,8 +943,7 @@ func buildRoutingGenerationFromPlugins(effective map[string]*LoadedPlugin, numbe
|
||||
bindings := generation.protocolIndex[indexKey]
|
||||
if len(bindings) > 0 {
|
||||
other := bindings[0]
|
||||
legacyProviders := len(plugin.Meta.ChannelTypes) > 0 && len(other.Plugin.Meta.ChannelTypes) > 0
|
||||
if !legacyProviders || claim.Name != other.Protocol {
|
||||
if claim.Name != other.Protocol {
|
||||
return nil, fmt.Errorf("plugin %s protocol %s %s model %q conflicts with plugin %s", plugin.Meta.Key, method, operation.Path, model, other.Plugin.Meta.Key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,32 +155,33 @@ func TestPreflightRoutingConflict(t *testing.T) {
|
||||
routingProtocolExport("openai_responses"))
|
||||
protocolRegistry := NewRegistry()
|
||||
require.NoError(t, protocolRegistry.ReplaceOverrides([]*LoadedPlugin{left}))
|
||||
protocolErr := PreflightRoutingConflict(protocolRegistry.Generation(), right)
|
||||
require.Error(t, protocolErr)
|
||||
assert.Contains(t, protocolErr.Error(), "preflight-left")
|
||||
assert.Contains(t, protocolErr.Error(), `model "shared-model" conflicts`)
|
||||
require.NoError(t, PreflightRoutingConflict(protocolRegistry.Generation(), right))
|
||||
}
|
||||
|
||||
func TestPerProtocolModelsStillConflictOnOverlap(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
first := mustCompileRoutingPlugin(t, "overlap-first", 0, `["shared-model"]`,
|
||||
`protocols: [{name: "openai_responses", models: ["shared-model"], supports: ["stream", "sync", "background"]}],`,
|
||||
routingProtocolExport("openai_responses"))
|
||||
second := mustCompileRoutingPlugin(t, "overlap-second", 0, `["shared-model"]`,
|
||||
`protocols: [{name: "openai_responses", models: ["shared-model"], supports: ["stream", "sync", "background"]}],`,
|
||||
routingProtocolExport("openai_responses"))
|
||||
|
||||
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{first, second}))
|
||||
routingErrors := registry.RoutingErrors()
|
||||
require.Len(t, routingErrors, 1, "one of two overlapping claims must be rejected from the generation")
|
||||
for _, message := range routingErrors {
|
||||
assert.Contains(t, message, "conflicts")
|
||||
func TestPerProtocolModelsAcceptSharedPluginClaims(t *testing.T) {
|
||||
for _, channelType := range []int{0, 24} {
|
||||
t.Run(fmt.Sprintf("first_channel_type_%d", channelType), func(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
first := mustCompileRoutingPlugin(t, "overlap-first", channelType, `["shared-model"]`,
|
||||
`protocols: [{name: "openai_responses", models: ["shared-model"], supports: ["stream", "sync", "background"]}],`, routingProtocolExport("openai_responses"))
|
||||
second := mustCompileRoutingPlugin(t, "overlap-second", 0, `["shared-model"]`,
|
||||
`protocols: [{name: "openai_responses", models: ["shared-model"], supports: ["stream", "sync", "background"]}],`, routingProtocolExport("openai_responses"))
|
||||
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{second, first}))
|
||||
assert.Empty(t, registry.RoutingErrors())
|
||||
generation := registry.Generation()
|
||||
candidates := generation.LookupEndpointCandidates("POST", "/v1/responses", "shared-model")
|
||||
require.Len(t, candidates, 2)
|
||||
assert.Same(t, first, candidates[0].Plugin)
|
||||
assert.Same(t, second, candidates[1].Plugin)
|
||||
assert.Equal(t, []*LoadedPlugin{first, second}, generation.PluginsByModel("shared-model"))
|
||||
metadata, ok := generation.GetByModel("shared-model")
|
||||
require.True(t, ok)
|
||||
assert.Same(t, first, metadata)
|
||||
copy := generation.PluginsByModel("shared-model")
|
||||
copy[0] = nil
|
||||
assert.Same(t, first, generation.PluginsByModel("shared-model")[0])
|
||||
})
|
||||
}
|
||||
binding, ok := registry.Generation().LookupEndpoint("POST", "/v1/responses", "shared-model")
|
||||
require.True(t, ok)
|
||||
candidates := registry.Generation().LookupEndpointCandidates("POST", "/v1/responses", "shared-model")
|
||||
assert.Len(t, candidates, 1)
|
||||
assert.NotNil(t, binding.Plugin)
|
||||
}
|
||||
|
||||
func TestProtocolClaimDecodeAndValidation(t *testing.T) {
|
||||
@@ -497,16 +498,6 @@ func TestRegistryExcludesConflictingPluginWithoutBlockingGeneration(t *testing.T
|
||||
`export const native = {status: function(ctx, task) { return task; }};`),
|
||||
expectedError: "route GET /vendor/jobs/:id conflicts",
|
||||
},
|
||||
{
|
||||
name: "endpoint model ownership",
|
||||
first: mustCompileRoutingPlugin(t, "endpoint-alpha", 0, `["shared-model"]`,
|
||||
`protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
|
||||
routingProtocolExport("openai_responses")),
|
||||
second: mustCompileRoutingPlugin(t, "endpoint-beta", 0, `["shared-model"]`,
|
||||
`protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
|
||||
routingProtocolExport("openai_responses")),
|
||||
expectedError: `model "shared-model" conflicts`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -30,6 +31,24 @@ type volcSignRequest struct {
|
||||
|
||||
func injectGlobals(runtime *sobek.Runtime, identity func() string, now func() time.Time, logOutput func(string)) error {
|
||||
utils := map[string]any{
|
||||
"hasCapability": HasCapability,
|
||||
"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"))
|
||||
}
|
||||
budget := &jsonStateBudget{ctx: context.Background(), bytes: MaxJSONToolBytes, nodes: maxJSONToolNodes}
|
||||
value, err := newJSONStateNode(call.Argument(0), 0, budget)
|
||||
if err != nil {
|
||||
panic(runtime.NewTypeError("json.clone: %s", err))
|
||||
}
|
||||
cloned, err := value.jsValue(runtime)
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(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))
|
||||
|
||||
Reference in new issue
Block a user