mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-05 18:21:16 +00:00
1047 lines
42 KiB
Go
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)
|
|
}
|