mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-20 05:39:07 +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.
118 lines
5.0 KiB
Go
118 lines
5.0 KiB
Go
package llm_router
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
|
)
|
|
|
|
// TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native
|
|
// Bedrock routing gap: the request model reaches the router already normalized
|
|
// (the parser strips the region/inference-profile prefix and version suffix),
|
|
// so a provider registered with the raw inference-profile id must still match.
|
|
func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) {
|
|
route := ProviderRoute{Bedrock: true, Models: []string{"us.anthropic.claude-haiku-4-5"}}
|
|
assert.True(t, routeClaimsModel(route, "anthropic.claude-haiku-4-5"),
|
|
"raw region-prefixed Bedrock model must match the normalized request model")
|
|
assert.False(t, routeClaimsModel(route, "anthropic.claude-opus-4-8"),
|
|
"a model outside the provider's list must not match")
|
|
|
|
// A provider registered with the already-normalized id also matches.
|
|
normalized := ProviderRoute{Bedrock: true, Models: []string{"anthropic.claude-haiku-4-5"}}
|
|
assert.True(t, routeClaimsModel(normalized, "anthropic.claude-haiku-4-5"),
|
|
"normalized Bedrock model must match")
|
|
|
|
// Non-Bedrock routes keep exact matching (no prefix stripping).
|
|
openai := ProviderRoute{Models: []string{"gpt-4o"}}
|
|
assert.True(t, routeClaimsModel(openai, "gpt-4o"), "exact model must match")
|
|
assert.False(t, routeClaimsModel(openai, "us.gpt-4o"),
|
|
"non-Bedrock routes must not strip a us. prefix")
|
|
}
|
|
|
|
// TestRouter_BedrockCountTokensRoutes pins that the token-counting action
|
|
// reaches the Bedrock route instead of denying as not-routable.
|
|
func TestRouter_BedrockCountTokensRoutes(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "bedrock-prod",
|
|
Bedrock: true,
|
|
Models: []string{"anthropic.claude-sonnet-4-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com",
|
|
}}})
|
|
|
|
in := newInputWithModelAndURL("anthropic.claude-sonnet-4-5",
|
|
"/model/anthropic.claude-sonnet-4-5-20250929-v1:0/count-tokens")
|
|
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "bedrock"})
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "count-tokens must route, not deny")
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "bedrock-runtime.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host)
|
|
}
|
|
|
|
// TestRouter_BedrockInferenceProfilesRoutes covers the startup lookups a
|
|
// client makes to resolve a configured inference profile. They carry no
|
|
// model, so before they were recognised they denied and wrote a policy
|
|
// rejection into the access log on every session start.
|
|
func TestRouter_BedrockInferenceProfilesRoutes(t *testing.T) {
|
|
bedrock := ProviderRoute{
|
|
ID: "bedrock-prod",
|
|
Bedrock: true,
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com",
|
|
}
|
|
openai := ProviderRoute{
|
|
ID: "openai-prod",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{openai, bedrock}})
|
|
|
|
for _, path := range []string{
|
|
"/inference-profiles?type=SYSTEM_DEFINED",
|
|
"/inference-profiles/us.anthropic.claude-sonnet-5",
|
|
} {
|
|
out, err := mw.Invoke(context.Background(), newModellessInput(path))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "%s must route", path)
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "bedrock-runtime.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host,
|
|
"%s must reach the Bedrock provider, not the first authorised one", path)
|
|
|
|
nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
|
assert.Equal(t, "true", nonInference, "%s carries no model to gate on", path)
|
|
}
|
|
}
|
|
|
|
// TestRouter_BedrockNamespacedInferenceProfilesStripsPrefix pins that the
|
|
// optional gateway namespace is removed before the request goes upstream.
|
|
func TestRouter_BedrockNamespacedInferenceProfilesStripsPrefix(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "bedrock-prod",
|
|
Bedrock: true,
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com",
|
|
}}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/bedrock/inference-profiles"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "/bedrock", out.Mutations.RewriteUpstream.StripPathPrefix,
|
|
"the namespace prefix must not reach the real Bedrock endpoint")
|
|
}
|