mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-26 01:21:30 +02:00
[proxy,management] Conform the Agent Network endpoint to the LLM gateway protocol Reviewed the proxy against Claude Code's published gateway contract. The transport layer already held up; fourteen gaps sat one layer up, in the model catalog and in the non-inference endpoints clients call. Two of them cost money. The catalog carried no claude-opus-5 or claude-sonnet-5, so an operator could not authorise the models coding agents default to — those requests denied as not-routable, or priced at zero where a catch-all carried them. And gateway records pin ParserID "openai" while the same record serves /v1/messages, so Anthropic responses were read with the OpenAI parser, which never looks at message_start where input tokens live: input metered as roughly zero on every stream and cost was skipped entirely. The rest fix requests refused for structural rather than policy reasons: model discovery denied for every account with a model allowlist, token counting denied on Bedrock and mis-parsed on Vertex, startup probes refused and written into the access log at every session start, and denials rendered in a shape no LLM client parses. Two changes are additive by design — the deny body keeps every field it had and adds the vendor's error object alongside, and body-level identity injection is now gated on the request's dialect so it stops sending OpenAI-shape fields into Anthropic bodies that reject them. The end-to-end work turned up one more: the discovery filter treated any slash in a model id as a gateway prefix, which would have dropped every self-hosted "Qwen/..." model from the picker.
456 lines
19 KiB
Go
456 lines
19 KiB
Go
//go:build e2e
|
|
|
|
package agentnetwork
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/e2e/harness"
|
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
|
)
|
|
|
|
// Models each catalog surface is registered with in the matrix below. They
|
|
// differ per provider so the router's choice is unambiguous: a request that
|
|
// lands on the wrong provider record fails the surface assertion instead of
|
|
// passing by coincidence.
|
|
const (
|
|
matrixAnthropicModel = "claude-sonnet-5"
|
|
matrixBedrockModel = "anthropic.claude-sonnet-5"
|
|
// matrixBedrockPathModel is what a Bedrock SDK client puts in the URL: a
|
|
// cross-region inference profile with a release date and version suffix.
|
|
// The proxy must normalise it back to matrixBedrockModel to route and price.
|
|
matrixBedrockPathModel = "us.anthropic.claude-sonnet-5-20250101-v1:0"
|
|
// matrixVertexModel differs from the Anthropic record's model on purpose:
|
|
// a shared id would leave two routes claiming it and make which one serves
|
|
// /v1/messages depend on declaration order.
|
|
matrixVertexModel = "claude-haiku-4-5"
|
|
matrixVertexProject = "e2e-project"
|
|
matrixVertexRegion = "us-east5"
|
|
)
|
|
|
|
// gatewayEnv is a connected client plus a set of provider records, all pointed
|
|
// at one mock upstream, so several wire shapes can be driven over a single
|
|
// tunnel.
|
|
type gatewayEnv struct {
|
|
endpoint string
|
|
proxyIP string
|
|
client *harness.Client
|
|
proxy *harness.Proxy
|
|
vllm *harness.VLLM
|
|
// providerIDs maps the catalog id to the created provider record id.
|
|
providerIDs map[string]string
|
|
}
|
|
|
|
// provisionGatewayMatrix brings up one mock upstream and one provider record
|
|
// per catalog surface, all authorised for the same group by a single policy.
|
|
// Sharing one proxy and client keeps the wire-shape cases to one tunnel setup;
|
|
// each case still creates its own session id so its access-log row is findable.
|
|
func provisionGatewayMatrix(t *testing.T, ctx context.Context) gatewayEnv {
|
|
t.Helper()
|
|
|
|
vllm, err := harness.StartVLLM(ctx, srv)
|
|
require.NoError(t, err, "start mock upstream")
|
|
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
|
|
|
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-matrix"})
|
|
require.NoError(t, err, "create group")
|
|
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
|
|
|
ephemeral := false
|
|
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
|
Name: "e2e-gw-matrix-client",
|
|
Type: "reusable",
|
|
ExpiresIn: 86400,
|
|
UsageLimit: 0,
|
|
AutoGroups: []string{grp.Id},
|
|
Ephemeral: &ephemeral,
|
|
})
|
|
require.NoError(t, err, "mint setup key")
|
|
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
|
|
|
// The mock ignores auth, so a dummy credential satisfies each catalog
|
|
// entry's auth template. Vertex is the exception: its api_key is a GCP
|
|
// service-account keyfile the proxy mints an OAuth token from, and a dummy
|
|
// one cannot mint. That is deliberate — the Vertex case below asserts on
|
|
// routing, which happens before the token mint.
|
|
dummyKey := "sk-gw-e2e"
|
|
dummyKeyfile := "keyfile::" + "e2e-not-a-real-service-account-key"
|
|
|
|
specs := []struct {
|
|
name string
|
|
catalogID string
|
|
apiKey string
|
|
models []api.AgentNetworkProviderModel
|
|
}{
|
|
{
|
|
name: "openai", catalogID: "openai_api", apiKey: dummyKey,
|
|
models: []api.AgentNetworkProviderModel{{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}},
|
|
},
|
|
{
|
|
name: "anthropic", catalogID: "anthropic_api", apiKey: dummyKey,
|
|
models: []api.AgentNetworkProviderModel{{Id: matrixAnthropicModel, InputPer1k: 0.003, OutputPer1k: 0.015}},
|
|
},
|
|
{
|
|
name: "bedrock", catalogID: "bedrock_api", apiKey: dummyKey,
|
|
models: []api.AgentNetworkProviderModel{{Id: matrixBedrockModel, InputPer1k: 0.003, OutputPer1k: 0.015}},
|
|
},
|
|
{
|
|
name: "vertex", catalogID: "vertex_ai_api", apiKey: dummyKeyfile,
|
|
models: []api.AgentNetworkProviderModel{{Id: matrixVertexModel, InputPer1k: 0.001, OutputPer1k: 0.005}},
|
|
},
|
|
}
|
|
|
|
providerIDs := make(map[string]string, len(specs))
|
|
ids := make([]string, 0, len(specs))
|
|
for _, spec := range specs {
|
|
key := spec.apiKey
|
|
models := spec.models
|
|
prov, perr := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
|
Name: "e2e-gw-" + spec.name,
|
|
ProviderId: spec.catalogID,
|
|
UpstreamUrl: vllm.URL,
|
|
ApiKey: &key,
|
|
Enabled: ptr(true),
|
|
Models: &models,
|
|
})
|
|
require.NoError(t, perr, "create %s provider", spec.name)
|
|
id := prov.Id
|
|
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) })
|
|
providerIDs[spec.catalogID] = id
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
// Uncapped token limit: never blocks the handful of tokens driven here, but
|
|
// switches on usage metering so consumption and cost land in the row.
|
|
enabled := true
|
|
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-gw-matrix",
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{grp.Id},
|
|
DestinationProviderIds: ids,
|
|
Limits: &api.AgentNetworkPolicyLimits{
|
|
TokenLimit: api.AgentNetworkPolicyTokenLimit{
|
|
Enabled: true,
|
|
GroupCap: 10_000_000,
|
|
UserCap: 10_000_000,
|
|
WindowSeconds: 60,
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err, "create policy")
|
|
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
|
|
|
endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-matrix", sk.Key)
|
|
return gatewayEnv{
|
|
endpoint: endpoint,
|
|
proxyIP: proxyIP,
|
|
client: cl,
|
|
proxy: px,
|
|
vllm: vllm,
|
|
providerIDs: providerIDs,
|
|
}
|
|
}
|
|
|
|
// connectClient starts a proxy and a tunnel client for the shared account and
|
|
// waits until the client can reach the proxy peer, returning the endpoint and
|
|
// the proxy's tunnel IP to pin requests to.
|
|
func connectClient(t *testing.T, ctx context.Context, name, setupKey string) (string, string, *harness.Client, *harness.Proxy) {
|
|
t.Helper()
|
|
|
|
settings, err := srv.GetSettings(ctx)
|
|
require.NoError(t, err, "read settings")
|
|
require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned")
|
|
|
|
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-"+name+"-proxy")
|
|
require.NoError(t, err, "mint proxy token")
|
|
px, err := harness.StartProxy(ctx, srv, proxyToken)
|
|
require.NoError(t, err, "start proxy")
|
|
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
|
|
|
|
cl, err := harness.StartClient(ctx, srv, setupKey)
|
|
require.NoError(t, err, "start client")
|
|
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
|
|
|
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
|
// The probe resolves the endpoint and its first packet wakes the lazy proxy
|
|
// peer, so WaitProxyPeer then observes it connected.
|
|
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
|
require.NoError(t, err, "resolve endpoint to proxy IP")
|
|
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
|
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
|
}
|
|
return settings.Endpoint, proxyIP, cl, px
|
|
}
|
|
|
|
// callUntil retries an HTTP call through the tunnel until it returns one of the
|
|
// wanted statuses or the deadline passes, absorbing the DNS and tunnel jitter
|
|
// the first call through a fresh tunnel can hit. The last status and body are
|
|
// returned either way so the caller can assert with real detail.
|
|
func callUntil(t *testing.T, call func() (int, string, error), want ...int) (int, string) {
|
|
t.Helper()
|
|
wanted := make(map[int]struct{}, len(want))
|
|
for _, w := range want {
|
|
wanted[w] = struct{}{}
|
|
}
|
|
|
|
var code int
|
|
var body string
|
|
deadline := time.Now().Add(90 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
c, b, err := call()
|
|
if err == nil {
|
|
code, body = c, b
|
|
if _, ok := wanted[code]; ok {
|
|
return code, body
|
|
}
|
|
}
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
return code, body
|
|
}
|
|
|
|
// TestGatewayProtocolProviderMatrix drives one request per wire shape over a
|
|
// single tunnel, with a provider record per catalog surface behind it. It is
|
|
// the regression net for the routing and parser-selection changes: each case
|
|
// asserts the surface the request was metered under and the token counts that
|
|
// surface's own usage block carries, so a request parsed by the wrong provider's
|
|
// parser meters zero and fails rather than passing on a coincidence.
|
|
func TestGatewayProtocolProviderMatrix(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
|
defer cancel()
|
|
|
|
env := provisionGatewayMatrix(t, ctx)
|
|
diag := func() string {
|
|
return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s",
|
|
env.vllm.Logs(context.Background()), env.proxy.Logs(context.Background()))
|
|
}
|
|
|
|
t.Run("openai chat completions", func(t *testing.T) {
|
|
session := "e2e-gw-openai"
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, harness.VLLMModel, "ping", session)
|
|
}, 200)
|
|
require.Equal(t, 200, code, "openai chat must succeed; body: %s%s", body, diag())
|
|
require.Contains(t, body, "chat.completion", "body must be an OpenAI completion; got: %s", body)
|
|
|
|
row := findAccessLogBySession(t, ctx, session)
|
|
require.NotNil(t, row.Provider)
|
|
assert.Equal(t, "openai", *row.Provider, "the OpenAI chat path must meter under the openai surface")
|
|
assert.Equal(t, int64(harness.VLLMChatInputTokens), row.InputTokens, "OpenAI usage block must be read")
|
|
assert.Equal(t, int64(harness.VLLMChatOutputTokens), row.OutputTokens)
|
|
})
|
|
|
|
t.Run("anthropic messages", func(t *testing.T) {
|
|
session := "e2e-gw-anthropic"
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, matrixAnthropicModel, "ping", session)
|
|
}, 200)
|
|
require.Equal(t, 200, code, "anthropic messages must succeed; body: %s%s", body, diag())
|
|
|
|
row := findAccessLogBySession(t, ctx, session)
|
|
require.NotNil(t, row.Provider)
|
|
assert.Equal(t, "anthropic", *row.Provider, "the /v1/messages path must meter under the anthropic surface")
|
|
// These counts only appear if the Anthropic parser read the response:
|
|
// its usage fields are named differently from the OpenAI block.
|
|
assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens,
|
|
"Anthropic input_tokens must be read; zero here means the wrong parser ran")
|
|
assert.Equal(t, int64(harness.VLLMMessagesOutputTokens), row.OutputTokens)
|
|
assert.Positive(t, row.CachedInputTokens, "the Anthropic cache-read bucket must be recorded")
|
|
assert.Positive(t, row.CostUsd, "a metered request must carry a cost")
|
|
require.NotNil(t, row.ResolvedProviderId)
|
|
assert.Equal(t, env.providerIDs["anthropic_api"], *row.ResolvedProviderId,
|
|
"a vendor-tagged request must not cross to another provider's record")
|
|
})
|
|
|
|
t.Run("bedrock invoke normalises the path model", func(t *testing.T) {
|
|
session := "e2e-gw-bedrock"
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.Bedrock(ctx, env.endpoint, env.proxyIP, matrixBedrockPathModel, "ping", session)
|
|
}, 200)
|
|
require.Equal(t, 200, code, "bedrock invoke must succeed; body: %s%s", body, diag())
|
|
|
|
row := findAccessLogBySession(t, ctx, session)
|
|
require.NotNil(t, row.Provider)
|
|
assert.Equal(t, "bedrock", *row.Provider, "a native Bedrock path must meter under the bedrock surface")
|
|
require.NotNil(t, row.Model)
|
|
assert.Equal(t, matrixBedrockModel, *row.Model,
|
|
"the inference-profile prefix, release date and version suffix must be normalised away")
|
|
assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens)
|
|
})
|
|
|
|
t.Run("anthropic token counting", func(t *testing.T) {
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/messages/count_tokens",
|
|
fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"ping"}]}`, matrixAnthropicModel),
|
|
[]string{"anthropic-version: 2023-06-01"})
|
|
}, 200)
|
|
assert.Equal(t, 200, code, "token counting must route rather than deny; body: %s%s", body, diag())
|
|
})
|
|
|
|
t.Run("bedrock token counting", func(t *testing.T) {
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.PostJSON(ctx, env.endpoint, env.proxyIP,
|
|
"/model/"+matrixBedrockPathModel+"/count-tokens",
|
|
`{"input":{"converse":{"messages":[{"role":"user","content":[{"text":"ping"}]}]}}}`, nil)
|
|
}, 200)
|
|
assert.Equal(t, 200, code,
|
|
"the Bedrock count-tokens action must route; denying it pushes counting onto the billable inference path; body: %s%s",
|
|
body, diag())
|
|
})
|
|
|
|
t.Run("vertex token counting reaches its provider", func(t *testing.T) {
|
|
// The dummy service-account key cannot mint an OAuth token, so the
|
|
// request stops at the upstream credential. Both outcomes render as
|
|
// 403, so the deny code is what distinguishes them: upstream_auth_failed
|
|
// means the path resolved to the Vertex route and only the credential
|
|
// failed, while model_not_routable would mean the method segment was
|
|
// swallowed into the model id and no route ever claimed it.
|
|
path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s/count-tokens:rawPredict",
|
|
matrixVertexProject, matrixVertexRegion, matrixVertexModel)
|
|
_, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path,
|
|
`{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"ping"}]}`, nil)
|
|
}, 403)
|
|
assert.NotContains(t, body, "model_not_routable",
|
|
"the count-tokens method segment must not be parsed as part of the model id; body: %s%s", body, diag())
|
|
assert.Contains(t, body, "llm_policy.upstream_auth_failed",
|
|
"the request must reach the Vertex route and fail only at the credential; body: %s%s", body, diag())
|
|
})
|
|
|
|
t.Run("connection warming probe", func(t *testing.T) {
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.Get(ctx, env.endpoint, env.proxyIP, "/api/hello", nil)
|
|
}, 200)
|
|
assert.NotEqual(t, 403, code,
|
|
"the warm-up probe carries no model and must not be refused as unroutable; body: %s%s", body, diag())
|
|
})
|
|
|
|
t.Run("unknown model denies in the caller's error shape", func(t *testing.T) {
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages,
|
|
"claude-not-a-real-model-9", "ping", "e2e-gw-unknown")
|
|
}, 403)
|
|
require.Equal(t, 403, code, "a model no provider claims must still be refused; body: %s%s", body, diag())
|
|
|
|
// The NetBird fields stay where they were for existing consumers.
|
|
assert.Contains(t, body, "llm_policy.model_not_routable", "the deny code must be preserved")
|
|
// And the vendor's own envelope rides alongside, so the client can show
|
|
// the reason instead of an unexplained API error.
|
|
assert.Contains(t, body, `"type":"error"`, "an Anthropic caller must get the Anthropic error envelope")
|
|
assert.Contains(t, body, "permission_error", "403 must map to the vendor's permission error type")
|
|
})
|
|
}
|
|
|
|
// TestModelDiscoveryWithModelAllowlist covers gateway model discovery on an
|
|
// account that restricts models, which is the configuration that broke: the
|
|
// listing carries no model, and the per-model allowlist fails closed on an
|
|
// undetermined one, so discovery denied for exactly the accounts using the
|
|
// feature. It also asserts the allowlist still refuses a model outside it, so
|
|
// the exemption cannot be read as a way around the gate.
|
|
func TestModelDiscoveryWithModelAllowlist(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
|
defer cancel()
|
|
|
|
vllm, err := harness.StartVLLM(ctx, srv)
|
|
require.NoError(t, err, "start mock upstream")
|
|
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
|
|
|
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-discovery"})
|
|
require.NoError(t, err, "create group")
|
|
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
|
|
|
ephemeral := false
|
|
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
|
Name: "e2e-gw-discovery-client",
|
|
Type: "reusable",
|
|
ExpiresIn: 86400,
|
|
UsageLimit: 0,
|
|
AutoGroups: []string{grp.Id},
|
|
Ephemeral: &ephemeral,
|
|
})
|
|
require.NoError(t, err, "mint setup key")
|
|
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
|
|
|
// One provider enumerating a single model, while the upstream's own listing
|
|
// advertises two. The proxy must serve the shorter list.
|
|
dummyKey := "sk-discovery-e2e"
|
|
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
|
Name: "e2e-gw-discovery",
|
|
ProviderId: "openai_api",
|
|
UpstreamUrl: vllm.URL,
|
|
ApiKey: &dummyKey,
|
|
Enabled: ptr(true),
|
|
Models: &[]api.AgentNetworkProviderModel{
|
|
{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002},
|
|
},
|
|
})
|
|
require.NoError(t, err, "create provider")
|
|
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
|
|
|
// The model allowlist is what makes this a regression test: without a
|
|
// guardrail enabled, discovery was never gated in the first place.
|
|
var gr api.AgentNetworkGuardrailRequest
|
|
gr.Name = "e2e-gw-discovery-allowlist"
|
|
gr.Checks.ModelAllowlist.Enabled = true
|
|
gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel}
|
|
guard, err := srv.CreateGuardrail(ctx, gr)
|
|
require.NoError(t, err, "create guardrail")
|
|
t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) })
|
|
|
|
enabled := true
|
|
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-gw-discovery",
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{grp.Id},
|
|
DestinationProviderIds: []string{prov.Id},
|
|
GuardrailIds: &[]string{guard.Id},
|
|
})
|
|
require.NoError(t, err, "create policy")
|
|
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
|
|
|
endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-discovery", sk.Key)
|
|
diag := func() string {
|
|
return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s",
|
|
vllm.Logs(context.Background()), px.Logs(context.Background()))
|
|
}
|
|
|
|
t.Run("listing is served and bounded by policy", func(t *testing.T) {
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return cl.Get(ctx, endpoint, proxyIP, "/v1/models?limit=1000", nil)
|
|
}, 200)
|
|
require.Equal(t, 200, code,
|
|
"discovery must not be refused because the request carries no model; body: %s%s", body, diag())
|
|
|
|
assert.Contains(t, body, harness.VLLMModel, "the authorised model must reach the picker")
|
|
assert.NotContains(t, body, harness.VLLMUnlistedModel,
|
|
"a model the policy does not authorise must not be offered; body: %s", body)
|
|
})
|
|
|
|
t.Run("allowlist still refuses a model outside it", func(t *testing.T) {
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat,
|
|
harness.VLLMUnlistedModel, "ping", "e2e-gw-discovery-blocked")
|
|
}, 403)
|
|
require.Equal(t, 403, code,
|
|
"exempting model-less endpoints must not exempt inference; body: %s%s", body, diag())
|
|
assert.True(t,
|
|
strings.Contains(body, "llm_policy.model_blocked") || strings.Contains(body, "llm_policy.model_not_routable"),
|
|
"the refusal must name a model policy code; body: %s", body)
|
|
})
|
|
|
|
t.Run("allowlisted model still routes", func(t *testing.T) {
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat,
|
|
harness.VLLMModel, "ping", "e2e-gw-discovery-allowed")
|
|
}, 200)
|
|
require.Equal(t, 200, code, "the allowlisted model must still be served; body: %s%s", body, diag())
|
|
})
|
|
}
|