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.
93 lines
3.6 KiB
Go
93 lines
3.6 KiB
Go
package middleware
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// decodeDeny renders a denial and returns the parsed body plus the status.
|
|
func decodeDeny(t *testing.T, reason *DenyReason, status int) (map[string]any, int) {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
RenderDenyResponse(rec, "llm_limit_check", reason, status)
|
|
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), "deny body must be valid JSON")
|
|
return body, rec.Code
|
|
}
|
|
|
|
// TestRenderDeny_AnthropicSurfaceMirrorsVendorShape covers a budget stop
|
|
// reaching Claude Code. The client only parses the Anthropic envelope, so
|
|
// without the mirror the user sees an unexplained API error instead of the
|
|
// reason their request was refused.
|
|
func TestRenderDeny_AnthropicSurfaceMirrorsVendorShape(t *testing.T) {
|
|
body, status := decodeDeny(t, &DenyReason{
|
|
Code: "llm_policy.budget_cap_exceeded",
|
|
Message: "LLM policy limit exceeded",
|
|
Surface: "anthropic",
|
|
}, http.StatusForbidden)
|
|
|
|
assert.Equal(t, http.StatusForbidden, status)
|
|
assert.Equal(t, "error", body["type"], "Anthropic errors carry type=error at the top level")
|
|
|
|
errObj, ok := body["error"].(map[string]any)
|
|
require.True(t, ok, "error must be an object")
|
|
assert.Equal(t, "permission_error", errObj["type"], "403 maps to permission_error")
|
|
assert.Equal(t, "LLM policy limit exceeded", errObj["message"])
|
|
|
|
// The NetBird fields stay put so existing consumers keep working.
|
|
assert.Equal(t, "llm_policy.budget_cap_exceeded", body["code"])
|
|
assert.Equal(t, "LLM policy limit exceeded", body["message"])
|
|
assert.Equal(t, "llm_limit_check", body["middleware"])
|
|
}
|
|
|
|
// TestRenderDeny_OpenAISurfaceMirrorsVendorShape pins the OpenAI envelope,
|
|
// which nests the code and carries no top-level type.
|
|
func TestRenderDeny_OpenAISurfaceMirrorsVendorShape(t *testing.T) {
|
|
body, _ := decodeDeny(t, &DenyReason{
|
|
Code: "llm_policy.model_blocked",
|
|
Message: "model is not in the policy allowlist",
|
|
Surface: "openai",
|
|
}, http.StatusForbidden)
|
|
|
|
assert.NotContains(t, body, "type", "OpenAI errors have no top-level type")
|
|
|
|
errObj, ok := body["error"].(map[string]any)
|
|
require.True(t, ok, "error must be an object")
|
|
assert.Equal(t, "invalid_request_error", errObj["type"])
|
|
assert.Equal(t, "llm_policy.model_blocked", errObj["code"], "the NetBird code rides in the vendor code field")
|
|
assert.Equal(t, "model is not in the policy allowlist", errObj["message"])
|
|
}
|
|
|
|
// TestRenderDeny_RateLimitStatusMapsToVendorRateLimit pins the mapping a
|
|
// client's backoff keys on.
|
|
func TestRenderDeny_RateLimitStatusMapsToVendorRateLimit(t *testing.T) {
|
|
body, status := decodeDeny(t, &DenyReason{
|
|
Code: "llm_policy.token_cap_exceeded",
|
|
Message: "LLM policy limit exceeded",
|
|
Surface: "anthropic",
|
|
}, http.StatusTooManyRequests)
|
|
|
|
assert.Equal(t, http.StatusTooManyRequests, status, "429 must survive the status clamp")
|
|
errObj := body["error"].(map[string]any)
|
|
assert.Equal(t, "rate_limit_error", errObj["type"])
|
|
}
|
|
|
|
// TestRenderDeny_NoSurfaceKeepsLegacyShape guards non-LLM middlewares and
|
|
// denials raised before a surface is known.
|
|
func TestRenderDeny_NoSurfaceKeepsLegacyShape(t *testing.T) {
|
|
body, _ := decodeDeny(t, &DenyReason{
|
|
Code: "llm_policy.model_not_routable",
|
|
Message: "no provider configured for model x",
|
|
}, http.StatusForbidden)
|
|
|
|
assert.NotContains(t, body, "type", "no surface means no vendor mirror")
|
|
assert.NotContains(t, body, "error", "no surface means no vendor mirror")
|
|
assert.Equal(t, "llm_policy.model_not_routable", body["code"])
|
|
}
|