mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-25 09:01:29 +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.
243 lines
9.9 KiB
Go
243 lines
9.9 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"
|
|
)
|
|
|
|
// The cases in this file cover behaviour that arrived from code review, after
|
|
// the gateway-protocol end-to-end tests were written. Each had unit coverage
|
|
// only; none needed a new harness capability, which is why they belong here
|
|
// rather than on a manual checklist.
|
|
|
|
// TestNonInferenceEndpointsAreAuthorised covers the two review findings on the
|
|
// endpoints that carry no body: the per-model lookup must be authorised
|
|
// against the same allowlist that bounds the listing beside it, and only a read
|
|
// method may claim the non-inference exemption that skips the token pre-flight.
|
|
func TestNonInferenceEndpointsAreAuthorised(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
|
defer cancel()
|
|
|
|
env := provisionDiscoveryProvider(t, ctx)
|
|
|
|
t.Run("lookup of an authorised model succeeds", func(t *testing.T) {
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMModel, nil)
|
|
}, 200)
|
|
assert.Equal(t, 200, code, "an allowlisted model must remain reachable; body: %s", body)
|
|
})
|
|
|
|
t.Run("lookup of an unauthorised model is refused", func(t *testing.T) {
|
|
code, body, err := env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMUnlistedModel, nil)
|
|
require.NoError(t, err, "request must reach the proxy")
|
|
assert.Equal(t, 403, code,
|
|
"a model the policy does not authorise must not be confirmed by the detail lookup; body: %s", body)
|
|
})
|
|
|
|
// A write must not claim the exemption that lets the listing skip the token
|
|
// pre-flight. The body names no model on purpose: that is what a request
|
|
// probing for the exemption looks like, and it is the case the method gate
|
|
// exists to refuse. (A POST that does name a model is a different thing —
|
|
// it routes and meters as the inference request it is.)
|
|
for _, path := range []string{"/v1/models", "/v1/models/" + harness.VLLMModel, "/api/hello"} {
|
|
t.Run("write to "+path+" is refused", func(t *testing.T) {
|
|
code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path,
|
|
`{"messages":[{"role":"user","content":"hi"}]}`, nil)
|
|
require.NoError(t, err, "request must reach the proxy")
|
|
assert.NotEqual(t, 200, code,
|
|
"a write to a non-inference path must not be served unmetered; body: %s", body)
|
|
})
|
|
}
|
|
|
|
// A request carrying the sub-agent attribution headers must still be served
|
|
// and metered normally. Asserting the ids themselves is not possible yet:
|
|
// the parser lifts them onto the request's metadata, but nothing persists
|
|
// them, so they have no queryable surface to check against.
|
|
t.Run("sub-agent headers do not disturb the request", func(t *testing.T) {
|
|
sessionID := fmt.Sprintf("e2e-session-agentid-%d", time.Now().UnixNano())
|
|
code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/chat/completions",
|
|
fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"Reply with exactly: pong"}]}`, harness.VLLMModel),
|
|
[]string{
|
|
"x-session-id: " + sessionID,
|
|
"x-claude-code-agent-id: agent-child-7",
|
|
"x-claude-code-parent-agent-id: agent-root-1",
|
|
})
|
|
require.NoError(t, err, "request must reach the proxy")
|
|
require.Equal(t, 200, code, "the request must succeed; body: %s", body)
|
|
|
|
row := findAccessLogBySession(t, ctx, sessionID)
|
|
assert.Positive(t, row.InputTokens, "the request must still be metered normally")
|
|
})
|
|
}
|
|
|
|
// TestDatedModelIdRouting covers both halves of the dated-id rule that review
|
|
// tightened: a dated id still reaches an undated registration, but a route
|
|
// pinned to one dated build must never serve a different one.
|
|
func TestDatedModelIdRouting(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
|
defer cancel()
|
|
|
|
const (
|
|
undated = "claude-sonnet-9"
|
|
datedA = "claude-sonnet-9-20250101"
|
|
datedB = "claude-sonnet-9-20250202"
|
|
)
|
|
|
|
t.Run("a dated id reaches its undated registration", func(t *testing.T) {
|
|
env := provisionModelProvider(t, ctx, "dated-undated", "anthropic_api", undated)
|
|
|
|
sessionID := fmt.Sprintf("e2e-session-dated-%d", time.Now().UnixNano())
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", sessionID)
|
|
}, 200)
|
|
require.Equal(t, 200, code, "a pinned release of a registered family must route; body: %s", body)
|
|
|
|
row := findAccessLogBySession(t, ctx, sessionID)
|
|
assert.Positive(t, row.InputTokens, "the dated request must price at the registered rate, not zero")
|
|
})
|
|
|
|
t.Run("a route pinned to one dated build refuses another", func(t *testing.T) {
|
|
env := provisionModelProvider(t, ctx, "dated-pinned", "anthropic_api", datedA)
|
|
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", "")
|
|
}, 200)
|
|
require.Equal(t, 200, code, "the exact dated id must still route; body: %s", body)
|
|
|
|
code, body, err := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedB, "Reply with exactly: pong", "")
|
|
require.NoError(t, err, "request must reach the proxy")
|
|
assert.Equal(t, 403, code,
|
|
"a provider pinned to one dated build must not serve another; body: %s", body)
|
|
})
|
|
}
|
|
|
|
// TestBedrockInferenceProfilesReachTheUpstream covers the startup lookup a
|
|
// Bedrock client makes. The proxy forwards it to the configured upstream rather
|
|
// than denying it, so what comes back is the upstream's answer — never a
|
|
// NetBird policy rejection.
|
|
func TestBedrockInferenceProfilesReachTheUpstream(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
|
defer cancel()
|
|
|
|
env := provisionModelProvider(t, ctx, "infprofiles", "bedrock_api", "anthropic.claude-sonnet-5")
|
|
|
|
code, body := callUntil(t, func() (int, string, error) {
|
|
return env.client.Get(ctx, env.endpoint, env.proxyIP, "/inference-profiles", nil)
|
|
}, 200)
|
|
|
|
assert.Equal(t, 200, code, "the lookup must reach the upstream; body: %s", body)
|
|
assert.NotContains(t, body, "llm_policy.",
|
|
"the proxy must not answer a control-plane lookup with a policy denial")
|
|
assert.Contains(t, body, "inferenceProfileSummaries",
|
|
"the upstream's own answer must come back untouched")
|
|
}
|
|
|
|
// provisionDiscoveryProvider brings up one mock-backed provider enumerating a
|
|
// single model, with an allowlist guardrail in effect, plus a connected client.
|
|
func provisionDiscoveryProvider(t *testing.T, ctx context.Context) pricedEnv {
|
|
t.Helper()
|
|
env := provisionModelProvider(t, ctx, "noninference", "openai_api", harness.VLLMModel)
|
|
|
|
var gr api.AgentNetworkGuardrailRequest
|
|
gr.Name = "e2e-noninference-allowlist-" + fmt.Sprint(time.Now().UnixNano())
|
|
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
|
|
_, err = srv.UpdatePolicy(ctx, env.policyID, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-noninference",
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{env.groupID},
|
|
DestinationProviderIds: []string{env.providerID},
|
|
GuardrailIds: &[]string{guard.Id},
|
|
})
|
|
require.NoError(t, err, "attach guardrail to policy")
|
|
return env
|
|
}
|
|
|
|
// provisionModelProvider brings up the mock, one provider under the given
|
|
// catalog id enumerating exactly one model, an authorising policy, and a
|
|
// connected proxy + client.
|
|
func provisionModelProvider(t *testing.T, ctx context.Context, name, catalogID, model string) pricedEnv {
|
|
t.Helper()
|
|
|
|
vllm, err := harness.StartVLLM(ctx, srv)
|
|
require.NoError(t, err, "start mock upstream")
|
|
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
|
|
|
suffix := strings.ToLower(name)
|
|
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gwr-" + suffix})
|
|
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-gwr-" + suffix + "-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")
|
|
|
|
dummyKey := "sk-gwr-e2e"
|
|
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
|
Name: "e2e-gwr-" + suffix,
|
|
ProviderId: catalogID,
|
|
UpstreamUrl: vllm.URL,
|
|
ApiKey: &dummyKey,
|
|
Enabled: ptr(true),
|
|
Models: &[]api.AgentNetworkProviderModel{
|
|
{Id: model, InputPer1k: 0.001, OutputPer1k: 0.002},
|
|
},
|
|
})
|
|
require.NoError(t, err, "create provider")
|
|
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) })
|
|
|
|
enabled := true
|
|
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
|
Name: "e2e-gwr-" + suffix,
|
|
Enabled: &enabled,
|
|
SourceGroups: []string{grp.Id},
|
|
DestinationProviderIds: []string{prov.Id},
|
|
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, "gwr-"+suffix, sk.Key)
|
|
return pricedEnv{
|
|
providerID: prov.Id,
|
|
groupID: grp.Id,
|
|
policyID: pol.Id,
|
|
upstream: vllm.URL,
|
|
endpoint: endpoint,
|
|
proxyIP: proxyIP,
|
|
client: cl,
|
|
proxy: px,
|
|
}
|
|
}
|