Files
new-api/pkg/jsplugin/routing_test.go
T

1047 lines
42 KiB
Go

package jsplugin
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRegistryIndexesNativeRoutesAndProtocolsInOneGeneration(t *testing.T) {
registry := NewRegistry()
plugin, err := CompilePlugin(routingTestPluginSource(
"routing-alpha",
50,
`["video-alpha"]`,
`routes: [
{method: "POST", path: "/vendor/videos", type: "submit", action: "generate", decode: "decodeVideo", render: "videoCreated"},
{method: "GET", path: "/vendor/videos/:task_id", type: "query", render: "videoStatus"}
],
protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
`export const native = {
decodeVideo: function(ctx) { return {kind: "submit", model: "video-alpha", requestBody: ctx.body.value}; },
videoCreated: function(ctx, task) { return task; },
videoStatus: function(ctx, task) { return task; }
};
export const protocols = {openai_responses: {
decodeRequest: function(ctx) { return {kind: "submit", model: ctx.model, requestBody: ctx.body.value}; },
renderEvents: function() { return {events: [], state: null, done: false}; },
renderFinal: function(ctx, task) { return task; }
}};`,
), Options{})
require.NoError(t, err)
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
generation := registry.Generation()
assert.Equal(t, uint64(1), generation.Number)
byKey, ok := generation.Get("routing-alpha")
require.True(t, ok)
assert.Same(t, plugin, byKey)
byType, ok := generation.GetByChannelType(50)
require.True(t, ok)
assert.Same(t, plugin, byType)
byModel, ok := generation.GetByModel("video-alpha")
require.True(t, ok)
assert.Same(t, plugin, byModel)
route, ok := generation.LookupDeclaredRoute("get", "/vendor/videos/:id")
require.True(t, ok)
assert.Equal(t, "routing-alpha", route.Plugin.Meta.Key)
assert.Equal(t, "task_id", route.Route.TaskIDParam)
endpoint, ok := generation.LookupEndpoint("POST", "/v1/responses", "video-alpha")
require.True(t, ok)
assert.Equal(t, "openai_responses", endpoint.Protocol)
assert.Equal(t, []ProtocolBinding{endpoint}, generation.LookupEndpointCandidates("POST", "/v1/responses", "video-alpha"))
}
func TestRegistryIndexesSharedEndpointCandidatesForDistinctLegacyProviders(t *testing.T) {
registry := NewRegistry()
gemini := mustCompileRoutingPlugin(t, "gemini-provider", 24, `["shared-video"]`,
`protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
routingProtocolExport("openai_responses"))
vertex := mustCompileRoutingPlugin(t, "vertex-provider", 41, `["shared-video"]`,
`protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
routingProtocolExport("openai_responses"))
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{vertex, gemini}))
candidates := registry.Generation().LookupEndpointCandidates("POST", "/v1/responses", "shared-video")
require.Len(t, candidates, 2)
assert.Equal(t, "gemini-provider", candidates[0].Plugin.Meta.Key)
assert.Equal(t, "vertex-provider", candidates[1].Plugin.Meta.Key)
assert.Empty(t, registry.RoutingErrors())
}
func TestSupportsRegisteredHostProtocols(t *testing.T) {
assert.True(t, SupportsHostProtocol("openai_responses"))
assert.True(t, SupportsHostProtocol("openai_video"))
assert.False(t, SupportsHostProtocol("plugin_owned_wire"))
}
func TestLookupHostProtocolOperationExcludesRetrieveWithoutModelField(t *testing.T) {
_, _, ok := LookupHostProtocolOperation(http.MethodGet, "/v1/responses/:response_id")
assert.False(t, ok)
_, _, ok = LookupHostProtocolOperation(http.MethodPost, "/v1/responses")
assert.True(t, ok)
}
func TestPerProtocolModelsNarrowEndpointBindings(t *testing.T) {
registry := NewRegistry()
plugin := mustCompileRoutingPlugin(t, "narrow-protocol", 50, `["gpt-5.5", "gpt-5.6"]`,
`protocols: [{name: "openai_responses", models: ["gpt-5.5"], supports: ["stream", "sync", "background"]}],`,
routingProtocolExport("openai_responses"))
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
generation := registry.Generation()
bound, ok := generation.LookupEndpoint("POST", "/v1/responses", "gpt-5.5")
require.True(t, ok)
assert.Same(t, plugin, bound.Plugin)
_, ok = generation.LookupEndpoint("POST", "/v1/responses", "gpt-5.6")
assert.False(t, ok, "model outside the protocol claim must fall through to the Go relay")
assert.Empty(t, generation.LookupEndpointCandidates("POST", "/v1/responses", "gpt-5.6"))
}
func TestPerProtocolModelsAllowDisjointPluginsOnSharedProtocol(t *testing.T) {
registry := NewRegistry()
left := mustCompileRoutingPlugin(t, "disjoint-left", 0, `["model-a"]`,
`protocols: [{name: "openai_responses", models: ["model-a"], supports: ["stream", "sync", "background"]}],`,
routingProtocolExport("openai_responses"))
right := mustCompileRoutingPlugin(t, "disjoint-right", 0, `["model-b"]`,
`protocols: [{name: "openai_responses", models: ["model-b"], supports: ["stream", "sync", "background"]}],`,
routingProtocolExport("openai_responses"))
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{left, right}))
generation := registry.Generation()
assert.Empty(t, registry.RoutingErrors())
boundA, ok := generation.LookupEndpoint("POST", "/v1/responses", "model-a")
require.True(t, ok)
assert.Same(t, left, boundA.Plugin)
boundB, ok := generation.LookupEndpoint("POST", "/v1/responses", "model-b")
require.True(t, ok)
assert.Same(t, right, boundB.Plugin)
}
func TestPreflightRoutingConflict(t *testing.T) {
first := mustCompileRoutingPlugin(t, "preflight-first", 90, `["model-a"]`, "", "")
second := mustCompileRoutingPlugin(t, "preflight-second", 90, `["model-b"]`, "", "")
replacement := mustCompileRoutingPlugin(t, "preflight-first", 90, `["model-a-v2"]`, "", "")
require.NoError(t, PreflightRoutingConflict(nil, first), "a lone candidate against a nil generation must be admitted")
registry := NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{first}))
current := registry.Generation()
err := PreflightRoutingConflict(current, second)
require.Error(t, err)
assert.Contains(t, err.Error(), "preflight-first")
assert.Contains(t, err.Error(), "channelType 90 conflicts")
require.NoError(t, PreflightRoutingConflict(current, replacement), "same-key re-upload must replace rather than self-conflict")
left := mustCompileRoutingPlugin(t, "preflight-left", 0, `["shared-model"]`,
`protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
routingProtocolExport("openai_responses"))
right := mustCompileRoutingPlugin(t, "preflight-right", 0, `["shared-model"]`,
`protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
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`)
}
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")
}
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) {
tests := []struct {
name string
models string
metaFields string
exports string
errContains string
wantProtocols []ProtocolClaim
}{
{
name: "string and object entries mix",
models: `["gpt-5.5", "gpt-5.6"]`,
metaFields: `protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}, {name: "openai_video", models: ["gpt-5.5"]}],`,
exports: `export const protocols = {
openai_responses: {
decodeRequest: function(ctx) { return ctx; },
renderEvents: function() { return {events: [], state: null, done: false}; },
renderFinal: function(ctx, task) { return task; }
},
openai_video: {
decodeRequest: function(ctx) { return ctx; },
render: function(ctx, task) { return task; }
}
};
export function listArtifacts() { return []; }
export function buildContentRequest() { return {}; }`,
wantProtocols: []ProtocolClaim{
{Name: "openai_responses", Supports: []string{"stream", "sync", "background"}, objectForm: true},
{Name: "openai_video", Models: []string{"gpt-5.5"}, objectForm: true},
},
},
{
name: "absent protocols is fine",
models: `["gpt-5.5"]`,
wantProtocols: []ProtocolClaim{},
},
{
name: "object with unknown field",
models: `["gpt-5.5"]`,
metaFields: `protocols: [{name: "openai_responses", models: ["gpt-5.5"], extra: true}],`,
errContains: `unknown field "extra"`,
},
{
name: "model outside meta models",
models: `["gpt-5.5"]`,
metaFields: `protocols: [{name: "openai_responses", models: ["gpt-9.9"], supports: ["stream", "sync", "background"]}],`,
errContains: "is not declared in plugin meta models",
},
{
name: "duplicate models in claim",
models: `["gpt-5.5"]`,
metaFields: `protocols: [{name: "openai_responses", models: ["gpt-5.5", "gpt-5.5"], supports: ["stream", "sync", "background"]}],`,
errContains: "models must be unique",
},
{
name: "blank model in claim",
models: `["gpt-5.5"]`,
metaFields: `protocols: [{name: "openai_responses", models: [" "], supports: ["stream", "sync", "background"]}],`,
errContains: "non-empty canonical names",
},
{
name: "empty models rejected",
models: `["gpt-5.5"]`,
metaFields: `protocols: [{name: "openai_responses", models: []}],`,
errContains: "models must contain at least one model",
},
{
name: "explicit null protocols",
models: `["gpt-5.5"]`,
metaFields: `protocols: null,`,
errContains: "must be an array",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
exports := testCase.exports
if exports == "" && testCase.errContains != "" {
exports = routingProtocolExport("openai_responses")
}
plugin, err := CompilePlugin(
routingTestPluginSource("claim-decode", 0, testCase.models, testCase.metaFields, exports),
Options{},
)
if testCase.errContains != "" {
require.ErrorContains(t, err, testCase.errContains)
return
}
require.NoError(t, err)
require.Equal(t, testCase.wantProtocols, plugin.Meta.Protocols)
})
}
}
func TestRouteModelsDecodeAndValidation(t *testing.T) {
routeExports := `export const native = {
decodeJob: function(ctx) { return {kind: "submit", model: "gpt-5.5", requestBody: ctx.body.value}; },
jobCreated: function(ctx, task) { return task; },
jobStatus: function(ctx, task) { return task; }
};`
tests := []struct {
name string
models string
metaFields string
errContains string
}{
{
name: "submit route with models",
models: `["gpt-5.5", "gpt-5.6"]`,
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: ["gpt-5.5"], decode: "decodeJob", render: "jobCreated"}],`,
},
{
name: "query route rejects models",
models: `["gpt-5.5"]`,
metaFields: `routes: [{method: "GET", path: "/v1/batch/:task_id", type: "query", models: ["gpt-5.5"], render: "jobStatus"}],`,
errContains: "must not declare models",
},
{
name: "model outside meta models",
models: `["gpt-5.5"]`,
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: ["gpt-9.9"], decode: "decodeJob", render: "jobCreated"}],`,
errContains: "is not declared in plugin meta models",
},
{
name: "duplicate models",
models: `["gpt-5.5"]`,
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: ["gpt-5.5", "gpt-5.5"], decode: "decodeJob", render: "jobCreated"}],`,
errContains: "models must be unique",
},
{
name: "blank model entry",
models: `["gpt-5.5"]`,
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: [" "], decode: "decodeJob", render: "jobCreated"}],`,
errContains: "non-empty canonical names",
},
{
name: "empty models rejected",
models: `["gpt-5.5"]`,
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", models: [], decode: "decodeJob", render: "jobCreated"}],`,
errContains: "models must contain at least one model",
},
{
name: "query route empty models rejected",
models: `["gpt-5.5"]`,
metaFields: `routes: [{method: "GET", path: "/v1/batch/:task_id", type: "query", models: [], render: "jobStatus"}],`,
errContains: "models must contain at least one model",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
plugin, err := CompilePlugin(
routingTestPluginSource("route-models", 0, testCase.models, testCase.metaFields, routeExports),
Options{},
)
if testCase.errContains != "" {
require.ErrorContains(t, err, testCase.errContains)
return
}
require.NoError(t, err)
require.Len(t, plugin.Meta.Routes, 1)
assert.Equal(t, []string{"gpt-5.5"}, plugin.Meta.Routes[0].Models)
})
}
}
func TestRouteRequestContextClonesFormAndMultipartValuesPerDecoder(t *testing.T) {
tests := []struct {
name string
body any
mutate func(map[string]any)
}{
{
name: "form fields",
body: map[string]any{"kind": "form", "fields": map[string][]string{"prompt": {"original"}}},
mutate: func(value map[string]any) {
value["body"].(map[string]any)["fields"].(map[string][]string)["prompt"][0] = "mutated"
},
},
{
name: "multipart files",
body: map[string]any{"kind": "multipart", "files": []map[string]any{{"ref": "request_file:image", "filename": "safe.png"}}},
mutate: func(value map[string]any) {
value["body"].(map[string]any)["files"].([]map[string]any)[0]["filename"] = "mutated.png"
},
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
request := RouteRequestContext{Body: testCase.body}
first := request.JSValue()
testCase.mutate(first)
second := request.JSValue()
assert.NotEqual(t, first, second)
})
}
}
func TestRegistryNoOpMutationsKeepCurrentGeneration(t *testing.T) {
registry := NewRegistry()
plugin := mustCompileRoutingPlugin(t, "stable-generation", 0, `["model"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
current := registry.Generation()
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
assert.Same(t, current, registry.Generation())
registry.SetOverrideEnabled(true)
assert.Same(t, current, registry.Generation())
require.NoError(t, registry.Unregister("missing"))
assert.Same(t, current, registry.Generation())
}
func TestAdjacentNodeGenerationsKeepPinnedPluginExecutable(t *testing.T) {
compileVersion := func(version string) *LoadedPlugin {
source := fmt.Sprintf(`
export const meta = {
apiVersion: 1, key: "adjacent-node", name: "Adjacent", version: %q,
author: {name: "Test"},
models: ["model"], fetchMode: "per_task"
};
export function buildSubmitRequest() { return {version: %q}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, version, version)
plugin, err := CompilePlugin(source, Options{})
require.NoError(t, err)
return plugin
}
nodeA := NewRegistry()
nodeB := NewRegistry()
nodeAV1 := compileVersion("1.0.0")
nodeBV1 := compileVersion("1.0.0")
require.NoError(t, nodeA.ReplaceOverrides([]*LoadedPlugin{nodeAV1}))
require.NoError(t, nodeB.ReplaceOverrides([]*LoadedPlugin{nodeBV1}))
nodeAV2 := compileVersion("2.0.0")
require.NoError(t, nodeA.ReplaceOverrides([]*LoadedPlugin{nodeAV2}))
assert.Equal(t, nodeB.Generation().Number+1, nodeA.Generation().Number)
pinned := PinnedPlugin{Generation: nodeB.Generation(), Plugin: nodeBV1}
nodeBV2 := compileVersion("2.0.0")
require.NoError(t, nodeB.ReplaceOverrides([]*LoadedPlugin{nodeBV2}))
oldResult, err := pinned.Plugin.Engine.Call(context.Background(), "buildSubmitRequest")
require.NoError(t, err)
oldObject, ok := oldResult.(map[string]any)
require.True(t, ok)
assert.Equal(t, "1.0.0", oldObject["version"])
current, ok := nodeB.Get("adjacent-node")
require.True(t, ok)
newResult, err := current.Engine.Call(context.Background(), "buildSubmitRequest")
require.NoError(t, err)
newObject, ok := newResult.(map[string]any)
require.True(t, ok)
assert.Equal(t, "2.0.0", newObject["version"])
assert.Equal(t, uint64(1), pinned.Generation.Number)
assert.Equal(t, uint64(2), nodeB.Generation().Number)
}
func TestRegistryReportsPartialAndFailedRebuildOutcomes(t *testing.T) {
registry := NewRegistry()
alpha := mustCompileRoutingPlugin(t, "outcome-alpha", 601, `["alpha"]`, "", "")
beta := mustCompileRoutingPlugin(t, "outcome-beta", 601, `["beta"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{alpha, beta}))
partial := registry.LastRebuildOutcome()
assert.Equal(t, "partial", partial.Status)
assert.Equal(t, registry.Generation().Number, partial.Generation)
assert.Empty(t, partial.Error)
err := registry.SetGenerationPreparer(func(_, _ *RoutingGeneration) (PreparedRoutingGeneration, error) {
return PreparedRoutingGeneration{}, fmt.Errorf("runtime rebuild unavailable")
})
require.ErrorContains(t, err, "runtime rebuild unavailable")
failed := registry.LastRebuildOutcome()
assert.Equal(t, "failed", failed.Status)
assert.Equal(t, registry.Generation().Number, failed.Generation)
assert.Contains(t, failed.Error, "runtime rebuild unavailable")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{alpha}))
success := registry.LastRebuildOutcome()
assert.Equal(t, "success", success.Status)
assert.Equal(t, registry.Generation().Number, success.Generation)
assert.Empty(t, success.Error)
}
func TestRegistryExcludesConflictingPluginWithoutBlockingGeneration(t *testing.T) {
tests := []struct {
name string
first *LoadedPlugin
second *LoadedPlugin
expectedError string
}{
{
name: "legacy channel type",
first: mustCompileRoutingPlugin(t, "channel-alpha", 50, `["alpha"]`, "", ""),
second: mustCompileRoutingPlugin(t, "channel-beta", 50, `["beta"]`, "", ""),
expectedError: "channelType 50 conflicts",
},
{
name: "overlapping channelTypes entry",
first: mustCompileRoutingPlugin(t, "channel-alpha", 0, `["alpha"]`, `channelTypes: [55, 1],`, ""),
second: mustCompileRoutingPlugin(t, "channel-beta", 0, `["beta"]`, `channelTypes: [1],`, ""),
expectedError: "channelType 1 conflicts",
},
{
name: "route shape",
first: mustCompileRoutingPlugin(t, "route-alpha", 0, `["alpha"]`,
`routes: [{method: "GET", path: "/vendor/jobs/:task_id", type: "query", render: "status"}],`,
`export const native = {status: function(ctx, task) { return task; }};`),
second: mustCompileRoutingPlugin(t, "route-beta", 0, `["beta"]`,
`routes: [{method: "GET", path: "/vendor/jobs/:id", type: "query", render: "status", taskIdParam: "id"}],`,
`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 {
t.Run(testCase.name, func(t *testing.T) {
registry := NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{testCase.first}))
before := registry.Generation()
err := registry.ReplaceOverrides([]*LoadedPlugin{testCase.first, testCase.second})
require.NoError(t, err)
assert.Equal(t, before.Number+1, registry.Generation().Number)
_, exists := registry.Get(testCase.second.Meta.Key)
assert.False(t, exists)
assert.Contains(t, registry.RoutingErrors()[testCase.second.Meta.Key], testCase.expectedError)
})
}
}
func TestRegistryRetainsIncumbentWhenUpdatedPluginConflicts(t *testing.T) {
registry := NewRegistry()
incumbent := mustCompileRoutingPlugin(t, "incumbent", 70, `["model-v1"]`, "", "")
other := mustCompileRoutingPlugin(t, "other", 71, `["other-model"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{incumbent, other}))
conflictingUpdate := mustCompileRoutingPlugin(t, "incumbent", 71, `["model-v2"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{conflictingUpdate, other}))
active, ok := registry.Get("incumbent")
require.True(t, ok)
assert.Same(t, incumbent, active)
assert.Same(t, conflictingUpdate, registry.OverridePlugins()["incumbent"])
assert.Same(t, incumbent, registry.ActiveOverridePlugins()["incumbent"])
require.Contains(t, registry.RoutingErrors(), "incumbent")
fixedUpdate := mustCompileRoutingPlugin(t, "incumbent", 72, `["model-v2"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{fixedUpdate, other}))
active, ok = registry.Get("incumbent")
require.True(t, ok)
assert.Same(t, fixedUpdate, active)
assert.NotContains(t, registry.RoutingErrors(), "incumbent")
}
func TestRegistryAdmitsNewConflictsInDeterministicKeyOrder(t *testing.T) {
registry := NewRegistry()
alpha := mustCompileRoutingPlugin(t, "alpha", 80, `["alpha-model"]`, "", "")
beta := mustCompileRoutingPlugin(t, "beta", 80, `["beta-model"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{beta, alpha}))
active, ok := registry.Get("alpha")
require.True(t, ok)
assert.Same(t, alpha, active)
_, betaActive := registry.Get("beta")
assert.False(t, betaActive)
assert.Contains(t, registry.RoutingErrors()["beta"], "channelType 80 conflicts")
}
func TestRegistryPublishesHealthyUpdateAlongsideRejectedUpdate(t *testing.T) {
registry := NewRegistry()
healthyV1 := mustCompileRoutingPlugin(t, "healthy", 81, `["healthy-v1"]`, "", "")
offenderV1 := mustCompileRoutingPlugin(t, "offender", 82, `["offender-v1"]`, "", "")
owner := mustCompileRoutingPlugin(t, "owner", 83, `["owner"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{healthyV1, offenderV1, owner}))
healthyV2 := mustCompileRoutingPlugin(t, "healthy", 84, `["healthy-v2"]`, "", "")
offenderV2 := mustCompileRoutingPlugin(t, "offender", 83, `["offender-v2"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{healthyV2, offenderV2, owner}))
activeHealthy, ok := registry.Get("healthy")
require.True(t, ok)
assert.Same(t, healthyV2, activeHealthy)
activeOffender, ok := registry.Get("offender")
require.True(t, ok)
assert.Same(t, offenderV1, activeOffender)
assert.Contains(t, registry.RoutingErrors()["offender"], "channelType 83 conflicts")
}
func TestRegistryAdmitsInterdependentUpdatesAsOneGeneration(t *testing.T) {
tests := []struct {
name string
firstType int
secondType int
}{
{name: "one update frees the type needed by another", firstType: 102, secondType: 103},
{name: "two plugins swap types", firstType: 102, secondType: 101},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
registry := NewRegistry()
firstV1 := mustCompileRoutingPlugin(t, "dependent-first", 101, `["first-v1"]`, "", "")
secondV1 := mustCompileRoutingPlugin(t, "dependent-second", 102, `["second-v1"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{firstV1, secondV1}))
firstV2 := mustCompileRoutingPlugin(t, "dependent-first", testCase.firstType, `["first-v2"]`, "", "")
secondV2 := mustCompileRoutingPlugin(t, "dependent-second", testCase.secondType, `["second-v2"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{firstV2, secondV2}))
activeFirst, ok := registry.Get("dependent-first")
require.True(t, ok)
assert.Same(t, firstV2, activeFirst)
activeSecond, ok := registry.Get("dependent-second")
require.True(t, ok)
assert.Same(t, secondV2, activeSecond)
assert.Empty(t, registry.RoutingErrors())
})
}
}
func TestRejectedUpdateDoesNotRestoreIncumbentAheadOfHealthyPeer(t *testing.T) {
registry := NewRegistry()
alphaV1 := mustCompileRoutingPlugin(t, "fallback-alpha", 111, `["alpha-v1"]`, "", "")
betaV1 := mustCompileRoutingPlugin(t, "fallback-beta", 112, `["beta-v1"]`, "", "")
owner := mustCompileRoutingPlugin(t, "fallback-owner", 113, `["owner"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{alphaV1, betaV1, owner}))
alphaV2 := mustCompileRoutingPlugin(t, "fallback-alpha", 113, `["alpha-v2"]`, "", "")
betaV2 := mustCompileRoutingPlugin(t, "fallback-beta", 111, `["beta-v2"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{alphaV2, betaV2, owner}))
_, alphaActive := registry.Get("fallback-alpha")
assert.False(t, alphaActive)
activeBeta, ok := registry.Get("fallback-beta")
require.True(t, ok)
assert.Same(t, betaV2, activeBeta)
activeOwner, ok := registry.Get("fallback-owner")
require.True(t, ok)
assert.Same(t, owner, activeOwner)
assert.Contains(t, registry.RoutingErrors()["fallback-alpha"], "channelType 113 conflicts")
assert.NotContains(t, registry.RoutingErrors(), "fallback-beta")
}
func TestRemovingOverrideNeverRetainsRemovedIncumbent(t *testing.T) {
registry := NewRegistry()
override := mustCompileRoutingPlugin(t, "fallback", 92, `["override"]`, "", "")
owner := mustCompileRoutingPlugin(t, "owner", 91, `["owner"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{override, owner}))
factory, err := registry.RegisterFactory(routingTestPluginSource("fallback", 91, `["factory"]`, "", ""), Options{})
require.NoError(t, err)
require.NoError(t, registry.Unregister("fallback"))
_, fallbackActive := registry.Get("fallback")
assert.False(t, fallbackActive)
ownerActive, ok := registry.Get("owner")
require.True(t, ok)
assert.Same(t, owner, ownerActive)
assert.NotContains(t, registry.OverridePlugins(), "fallback")
assert.NotContains(t, registry.ActiveOverridePlugins(), "fallback")
assert.Contains(t, registry.RoutingErrors()["fallback"], "channelType 91 conflicts")
require.NoError(t, registry.Unregister("owner"))
fallbackPlugin, ok := registry.Get("fallback")
require.True(t, ok)
assert.Same(t, factory, fallbackPlugin)
}
func TestRejectedNewOverrideRetainsFactoryIncumbent(t *testing.T) {
registry := NewRegistry()
factorySource := routingTestPluginSource("factory-fallback", 121, `["factory"]`, "", "")
factory, err := registry.RegisterFactory(factorySource, Options{})
require.NoError(t, err)
owner := mustCompileRoutingPlugin(t, "factory-owner", 122, `["owner"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{owner}))
conflictingOverride := mustCompileRoutingPlugin(t, "factory-fallback", 122, `["override"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{conflictingOverride, owner}))
active, ok := registry.Get("factory-fallback")
require.True(t, ok)
assert.Same(t, factory, active)
assert.Same(t, conflictingOverride, registry.OverridePlugins()["factory-fallback"])
assert.NotContains(t, registry.ActiveOverridePlugins(), "factory-fallback")
assert.Contains(t, registry.RoutingErrors()["factory-fallback"], "channelType 122 conflicts")
}
func TestDisablingOverridesPublishesFactoryInsteadOfRetainingOverride(t *testing.T) {
registry := NewRegistry()
factorySource := routingTestPluginSource("switchable", 93, `["factory"]`, "", "")
factory, err := registry.RegisterFactory(factorySource, Options{})
require.NoError(t, err)
override := mustCompileRoutingPlugin(t, "switchable", 94, `["override"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{override}))
registry.SetOverrideEnabled(false)
active, ok := registry.Get("switchable")
require.True(t, ok)
assert.Same(t, factory, active)
assert.Same(t, override, registry.OverridePlugins()["switchable"])
assert.Empty(t, registry.ActiveOverridePlugins())
}
func TestGenericChannelTypesDoNotCreateLegacyIdentityConflicts(t *testing.T) {
for _, channelType := range []int{0, constant.ChannelTypeTaskPlugin} {
t.Run(fmt.Sprintf("channel_%d", channelType), func(t *testing.T) {
registry := NewRegistry()
first := mustCompileRoutingPlugin(t, "generic-alpha", channelType, `["alpha"]`, "", "")
second := mustCompileRoutingPlugin(t, "generic-beta", channelType, `["beta"]`, "", "")
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{first, second}))
_, found := registry.GetByChannelType(channelType)
assert.False(t, found)
})
}
}
func TestDeclarativeRouteValidationProtectsNamespacesAndCanonicalSyntax(t *testing.T) {
tests := []struct {
name string
route Route
expectedError string
}{
{
name: "core API",
route: Route{Method: "POST", Path: "/api/plugin", Type: RouteTypeSubmit},
expectedError: "reserved namespace /api",
},
{
name: "generic task management",
route: Route{Method: "GET", Path: "/v1/tasks/:task_id", Type: RouteTypeQuery, Render: "native"},
expectedError: "reserved namespace /v1/tasks",
},
{
name: "SPA root",
route: Route{Method: "POST", Path: "/console/jobs", Type: RouteTypeSubmit},
expectedError: "reserved namespace /console",
},
{
name: "dynamic root can claim reserved subtree",
route: Route{Method: "POST", Path: "/:root/jobs", Type: RouteTypeSubmit},
expectedError: "intersects reserved namespace",
},
{
name: "dynamic v1 namespace can claim task management",
route: Route{Method: "POST", Path: "/v1/:namespace", Type: RouteTypeSubmit},
expectedError: "reserved namespace /v1/tasks",
},
{
name: "root catch-all can claim reserved subtree",
route: Route{Method: "POST", Path: "/*rest", Type: RouteTypeSubmit},
expectedError: "intersects reserved namespace",
},
{
name: "repeated slash",
route: Route{Method: "POST", Path: "/vendor//jobs", Type: RouteTypeSubmit},
expectedError: "empty segments",
},
{
name: "noncanonical method",
route: Route{Method: " post ", Path: "/vendor/jobs", Type: RouteTypeSubmit},
expectedError: "canonical uppercase",
},
{
name: "non-terminal catch-all",
route: Route{Method: "POST", Path: "/vendor/*rest/jobs", Type: RouteTypeSubmit},
expectedError: "invalid catch-all",
},
{
name: "query id mismatch",
route: Route{Method: "GET", Path: "/vendor/jobs/:id", Type: RouteTypeQuery, Render: "native"},
expectedError: "must contain :task_id",
},
{
name: "query declares decoder",
route: Route{Method: "GET", Path: "/vendor/jobs/:task_id", Type: RouteTypeQuery, Decode: "decode", Render: "show"},
expectedError: "must not declare decode",
},
{
name: "submit missing presenter",
route: Route{Method: "POST", Path: "/vendor/jobs", Type: RouteTypeSubmit, Decode: "decode"},
expectedError: "must declare decode and render",
},
{
name: "dynamic missing decoder",
route: Route{Method: "POST", Path: "/vendor/query", Type: RouteTypeDynamic, Render: "show"},
expectedError: "must declare decode and render",
},
{
name: "dynamic missing presenter",
route: Route{Method: "POST", Path: "/vendor/query", Type: RouteTypeDynamic, Decode: "decode"},
expectedError: "must declare decode and render",
},
{
name: "query declares action",
route: Route{Method: "GET", Path: "/vendor/jobs/:task_id", Type: RouteTypeQuery, Render: "show", Action: "retrieve"},
expectedError: "must not declare action",
},
{
name: "submit declares task id parameter",
route: Route{Method: "POST", Path: "/vendor/jobs", Type: RouteTypeSubmit, Decode: "decode", Render: "created", TaskIDParam: "task_id"},
expectedError: "must not declare taskIdParam",
},
{
name: "dynamic declares task id parameter",
route: Route{Method: "POST", Path: "/vendor/query", Type: RouteTypeDynamic, Decode: "decode", Render: "show", TaskIDParam: "task_id"},
expectedError: "must not declare taskIdParam",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
meta := Meta{APIVersion: 1, Key: "validation", Name: "Validation", Version: "1.0.0", Author: AuthorMeta{Name: "Test"}, Models: []string{"model"}, FetchMode: "per_task", Routes: []Route{testCase.route}}
require.ErrorContains(t, ValidateV1Meta(meta), testCase.expectedError)
})
}
}
func TestRemovedEndpointsAndProtocolHooksAreValidatedAtCompileTime(t *testing.T) {
removed := routingTestPluginSource(
"removed-endpoint",
0,
`["model"]`,
`endpoints: [{method: "POST", path: "/v1/chat/completions", protocol: "chat"}],`,
routingProtocolExport("chat"),
)
_, err := CompilePlugin(removed, Options{})
require.ErrorContains(t, err, "endpoints is no longer supported")
unknown := routingTestPluginSource("unknown-protocol", 0, `["model"]`, `protocols: ["chat"],`, ``)
_, err = CompilePlugin(unknown, Options{})
require.ErrorContains(t, err, `protocol "chat" is unknown`)
missingHook := routingTestPluginSource(
"bad-protocol",
0,
`["model"]`,
`protocols: ["openai_responses"],`,
`export const protocols = {openai_responses: {
decodeRequest: function() { return {}; },
renderEvents: function() { return {events: [], done: false}; }
}};`,
)
_, err = CompilePlugin(missingHook, Options{})
require.ErrorContains(t, err, `plugin bad-protocol protocol "openai_responses" must declare supports; replace the bare string with {name: "openai_responses", supports: [...]} choosing from "stream", "sync", "background"`)
for _, removedExport := range []string{"renderers", "renderError", "resolveRequest"} {
t.Run("removed export "+removedExport, func(t *testing.T) {
source := routingTestPluginSource("removed-export", 0, `["model"]`, "", `export const `+removedExport+` = {};`)
_, compileErr := CompilePlugin(source, Options{})
require.ErrorContains(t, compileErr, `export "`+removedExport+`" is no longer supported`)
})
}
t.Run("removed route renderer", func(t *testing.T) {
source := routingTestPluginSource(
"removed-route-renderer", 0, `["model"]`,
`routes: [{method:"GET",path:"/vendor/:task_id",type:"query",render:"show",renderer:"legacy"}],`,
`export const native = {show: function(ctx, task) { return task; }};`,
)
_, compileErr := CompilePlugin(source, Options{})
require.ErrorContains(t, compileErr, "field renderer is no longer supported")
})
}
func TestRegistryRejectsPrototypeInheritedHooks(t *testing.T) {
for _, member := range []string{"constructor", "toString", "__proto__"} {
t.Run("native "+member, func(t *testing.T) {
source := routingTestPluginSource(
"inherited-renderer",
0,
`["model"]`,
fmt.Sprintf(`routes: [{method: "GET", path: "/vendor/jobs/:task_id", type: "query", render: %q}],`, member),
`const nativePrototype = {
constructor: function(task) { return task; },
toString: function(task) { return task; },
["__proto__"]: function(task) { return task; },
};
export const native = Object.create(nativePrototype);`,
)
_, err := CompilePlugin(source, Options{})
require.ErrorContains(t, err, fmt.Sprintf(`references missing native render %q`, member))
})
}
t.Run("protocol object", func(t *testing.T) {
source := routingTestPluginSource(
"inherited-protocol",
0,
`["model"]`,
`protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
`const protocol = {
decodeRequest: function(ctx) { return ctx; },
renderEvents: function() { return {events: [], state: null, done: false}; },
renderFinal: function(ctx, task) { return task; },
};
export const protocols = Object.create({openai_responses: protocol});`,
)
_, err := CompilePlugin(source, Options{})
require.ErrorContains(t, err, `protocol "openai_responses" is missing hook "decodeRequest"`)
})
t.Run("protocol hook", func(t *testing.T) {
source := routingTestPluginSource(
"inherited-protocol-hook",
0,
`["model"]`,
`protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
`const protocolPrototype = {
decodeRequest: function(ctx) { return ctx; },
renderEvents: function() { return {events: [], state: null, done: false}; },
renderFinal: function(ctx, task) { return task; },
};
export const protocols = {openai_responses: Object.create(protocolPrototype)};`,
)
_, err := CompilePlugin(source, Options{})
require.ErrorContains(t, err, `protocol "openai_responses" is missing hook "decodeRequest"`)
})
}
func TestMetaDecoderRejectsLossyOrUnknownRoutingFields(t *testing.T) {
tests := []struct {
name string
metaFields string
expectedError string
}{
{
name: "fractional channel type",
metaFields: `channelTypes: [50.5],`,
expectedError: "channelTypes element 1 must be an integer",
},
{
name: "non-string model",
metaFields: `models: ["model", 2],`,
expectedError: "models must be an array of strings",
},
{
name: "unknown route field",
metaFields: `routes: [{method: "POST", path: "/vendor/jobs", type: "submit", auth: false}],`,
expectedError: `unknown field "auth"`,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
source := fmt.Sprintf(`
export const meta = {
apiVersion: 1, key: "strict-meta", name: "Strict", version: "1.0.0",
author: {name: "Test"},
models: ["model"], fetchMode: "per_task", %s
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
export function resolveRequest() { return {kind: "submit", model: "model"}; }
`, testCase.metaFields)
_, err := CompilePlugin(source, Options{})
require.ErrorContains(t, err, testCase.expectedError)
})
}
}
func TestProtocolHookInspectionIsDeadlineBounded(t *testing.T) {
source := `
export const meta = {
apiVersion: 1, key: "getter-timeout", name: "Getter", version: "1.0.0",
author: {name: "Test"},
models: ["model"], fetchMode: "per_task",
protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
export const protocols = {
get openai_responses() { while (true) {} }
};
`
_, err := CompilePlugin(source, Options{Timeout: 20 * time.Millisecond})
require.ErrorContains(t, err, "inspection interrupted")
}
func TestReservedNamespaceChecksUseSegmentBoundaries(t *testing.T) {
meta := Meta{
APIVersion: 1,
Key: "apiary",
Name: "Apiary",
Version: "1.0.0",
Author: AuthorMeta{Name: "Test"},
Models: []string{"model"},
FetchMode: "per_task",
Routes: []Route{{Method: "POST", Path: "/apiary/jobs", Type: RouteTypeSubmit, Decode: "decode", Render: "render"}},
}
require.NoError(t, ValidateV1Meta(meta))
}
func TestResolveRouteActionPrefersResolvedAction(t *testing.T) {
route := Route{Action: "manifest-action"}
assert.Equal(t, "hook-action", ResolveRouteAction(route, "hook-action"))
assert.Equal(t, "manifest-action", ResolveRouteAction(route, ""))
}
func mustCompileRoutingPlugin(t *testing.T, key string, channelType int, models, metaFields, exports string) *LoadedPlugin {
t.Helper()
plugin, err := CompilePlugin(routingTestPluginSource(key, channelType, models, metaFields, exports), Options{})
require.NoError(t, err)
return plugin
}
func routingTestPluginSource(key string, channelType int, models, metaFields, exports string) string {
channelTypesField := ""
if channelType > 0 && channelType != constant.ChannelTypeTaskPlugin {
channelTypesField = fmt.Sprintf("channelTypes: [%d],", channelType)
}
return fmt.Sprintf(`
export const meta = {
apiVersion: 1,
key: %q,
name: %q,
version: "1.0.0",
author: {name: "Test"},
%s
models: %s,
fetchMode: "per_task",
%s
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
%s
`, key, key, channelTypesField, models, metaFields, exports)
}
func routingProtocolExport(name string) string {
return fmt.Sprintf(`export const protocols = {%s: {
decodeRequest: function(ctx) { return ctx; },
renderEvents: function() { return {events: [], state: null, done: false}; },
renderFinal: function(ctx, task) { return task; }
}};`, name)
}