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.
65 lines
2.6 KiB
Go
65 lines
2.6 KiB
Go
package llm_request_parser
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
|
)
|
|
|
|
func TestParseBedrockPath(t *testing.T) {
|
|
tests := []struct {
|
|
path string
|
|
model string
|
|
stream bool
|
|
ok bool
|
|
}{
|
|
{"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke", "anthropic.claude-sonnet-4-5", false, true},
|
|
{"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke-with-response-stream", "anthropic.claude-sonnet-4-5", true, true},
|
|
{"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/converse", "anthropic.claude-sonnet-4-5", false, true},
|
|
{"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/converse-stream", "anthropic.claude-sonnet-4-5", true, true},
|
|
// URL-encoded colon in the version suffix.
|
|
{"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke", "anthropic.claude-sonnet-4-5", false, true},
|
|
// Optional "/bedrock" gateway-namespace prefix.
|
|
{"/bedrock/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke-with-response-stream", "anthropic.claude-sonnet-4-5", true, true},
|
|
{"/bedrock/model/anthropic.claude-sonnet-4-5-20250929-v1:0/converse", "anthropic.claude-sonnet-4-5", false, true},
|
|
{"/v1/chat/completions", "", false, false},
|
|
{"/model/foo", "", false, false},
|
|
{"/model//invoke", "", false, false},
|
|
{"/model/x/unknown-action", "", false, false},
|
|
}
|
|
for _, tt := range tests {
|
|
br, ok := parseBedrockPath(tt.path)
|
|
require.Equal(t, tt.ok, ok, "ok for %q", tt.path)
|
|
if tt.ok {
|
|
require.Equal(t, tt.model, br.model, "model for %q", tt.path)
|
|
require.Equal(t, tt.stream, br.stream, "stream for %q", tt.path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInvoke_BedrockCountTokens covers the dedicated token-counting
|
|
// endpoint. Denying it does not break the client, it just pushes context
|
|
// counting back onto the inference endpoint, which is billable.
|
|
func TestInvoke_BedrockCountTokens(t *testing.T) {
|
|
mw := newMiddleware(t)
|
|
|
|
out, err := mw.Invoke(context.Background(), &middleware.Input{
|
|
URL: "/model/us.anthropic.claude-sonnet-4-5-20250929-v1:0/count-tokens",
|
|
Body: []byte(`{"input":{"converse":{"messages":[]}}}`),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision)
|
|
|
|
model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel)
|
|
require.True(t, ok, "count-tokens carries a model in the path and must emit it")
|
|
assert.Equal(t, "anthropic.claude-sonnet-4-5", model, "model must be normalized like any other action")
|
|
|
|
stream, _ := metaValue(t, out.Metadata, middleware.KeyLLMStream)
|
|
assert.Equal(t, "false", stream, "count-tokens never streams")
|
|
}
|