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:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+48 -24
View File
@@ -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
View File
@@ -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) {
+428
View File
@@ -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
View File
@@ -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
}
+171
View File
@@ -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
View File
@@ -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)
}
}
+24 -33
View File
@@ -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 {
+19
View File
@@ -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))