mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-18 13:41:30 +02:00
Compare commits
26 Commits
worktree-g
...
agent-netw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
520c912c07 | ||
|
|
9cd7e27027 | ||
|
|
0783605690 | ||
|
|
dc91325ac1 | ||
|
|
1796b2a1d8 | ||
|
|
dd87760f3d | ||
|
|
63d16a7cd4 | ||
|
|
fa691721d0 | ||
|
|
d46e2574a7 | ||
|
|
649a867cd3 | ||
|
|
a39b3c4af4 | ||
|
|
b1337f09d0 | ||
|
|
08e187ccb7 | ||
|
|
93cdf64a19 | ||
|
|
c04fb1c388 | ||
|
|
5353cab54f | ||
|
|
a64a417ea5 | ||
|
|
652c5c8b68 | ||
|
|
4d2b8b407b | ||
|
|
03e02c86ce | ||
|
|
789d416215 | ||
|
|
6415215126 | ||
|
|
1ae352a08d | ||
|
|
d928bcb630 | ||
|
|
47b2667653 | ||
|
|
875dda1708 |
@@ -1,6 +1,6 @@
|
||||
# NetBird Agent Guidelines
|
||||
|
||||
**NetBird** is an open source connectivity platform: a WireGuard®-based overlay
|
||||
**NetBird** is an open-source connectivity platform: a WireGuard®-based overlay
|
||||
network with a control plane. The **agent** (`client/`) runs on user machines as
|
||||
a privileged daemon and manages the WireGuard interface, routing, firewall, and
|
||||
DNS. **Management** (`management/`) is the control plane and REST/gRPC API,
|
||||
|
||||
@@ -479,7 +479,7 @@ go test -race ./client/internal/dns/...
|
||||
|
||||
## Checklist before submitting a PR
|
||||
|
||||
As a critical network service and open source project, we must enforce a few
|
||||
As a critical network service and open-source project, we must enforce a few
|
||||
things before submitting a pull request. The
|
||||
[pull request template](/.github/pull_request_template.md) mirrors this list —
|
||||
fill it in rather than deleting it.
|
||||
|
||||
@@ -130,7 +130,7 @@ In November 2022, NetBird joined the [StartUpSecure program](https://www.forschu
|
||||

|
||||
|
||||
### Acknowledgements
|
||||
We build on open source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
||||
We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
||||
|
||||
### Legal
|
||||
This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
||||
|
||||
@@ -14,7 +14,7 @@ Report security issues one of these two ways:
|
||||
on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
|
||||
- **Email** — `security@netbird.io`.
|
||||
|
||||
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open source code, email us rather than
|
||||
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than
|
||||
filing a repository report.
|
||||
|
||||
### What to include
|
||||
|
||||
@@ -40,6 +40,35 @@ You can then use this private endpoint to configure your AI agents, whether that
|
||||
Full step-by-step setup:
|
||||
**https://docs.netbird.io/agent-network/quickstart**
|
||||
|
||||
## Client settings that don't follow the endpoint
|
||||
|
||||
Most of an agent's traffic follows the base URL you hand it, but a few
|
||||
client-side checks call their vendor directly and never reach the proxy. On a
|
||||
network that blocks direct egress they fail even though inference works, so
|
||||
they are worth setting once when you roll the endpoint out.
|
||||
|
||||
For Claude Code:
|
||||
|
||||
- **Fast mode** checks availability against `api.anthropic.com` rather than the
|
||||
configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the
|
||||
agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when
|
||||
the proxy injects the real provider key) or when a TLS-inspecting proxy
|
||||
answers the check itself. Set
|
||||
`CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the
|
||||
connection outright. Fast mode is an Anthropic-API feature, so it is
|
||||
unavailable on a Bedrock- or Vertex-backed endpoint whatever you set.
|
||||
- **Model discovery** is off by default. Set
|
||||
`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the
|
||||
models your policies authorise; the proxy filters the response to that set.
|
||||
The client gives discovery a three-second budget and treats any redirect as
|
||||
a failure, so the endpoint must serve `/v1/models` directly.
|
||||
- **The WebFetch domain safety check** also calls `api.anthropic.com` directly
|
||||
and is unaffected by the variables above.
|
||||
|
||||
Allowing direct egress to `api.anthropic.com` covers the network cases but not
|
||||
the credential one, where the check reaches Anthropic and is rejected because
|
||||
the agent presents a proxy-issued key.
|
||||
|
||||
## Architecture
|
||||
|
||||
Agent Network is built on two existing NetBird capabilities:
|
||||
|
||||
@@ -53,15 +53,15 @@ func NewProxyBind(bind Bind, mtu uint16) *ProxyBind {
|
||||
return p
|
||||
}
|
||||
|
||||
// AddRelayedConn adds a new connection to the bind.
|
||||
// AddTurnConn adds a new connection to the bind.
|
||||
// endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the
|
||||
// WireGuard configuration.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context is used for proxyToLocal to avoid unnecessary error messages
|
||||
// - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address
|
||||
// - remoteConn: The established relayed connection to the remote peer
|
||||
func (p *ProxyBind) AddRelayedConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
|
||||
// - remoteConn: The established TURN connection to the remote peer
|
||||
func (p *ProxyBind) AddTurnConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
|
||||
fakeNetIP, err := fakeAddress(nbAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -30,9 +30,9 @@ type WGEBPFProxy struct {
|
||||
proxyPort int
|
||||
mtu uint16
|
||||
|
||||
ebpfManager ebpfMgr.Manager
|
||||
relayedConnStore map[uint16]net.Conn
|
||||
relayedConnMutex sync.Mutex
|
||||
ebpfManager ebpfMgr.Manager
|
||||
turnConnStore map[uint16]net.Conn
|
||||
turnConnMutex sync.Mutex
|
||||
|
||||
lastUsedPort uint16
|
||||
rawConnIPv4 net.PacketConn
|
||||
@@ -50,7 +50,7 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy {
|
||||
localWGListenPort: wgPort,
|
||||
mtu: mtu,
|
||||
ebpfManager: ebpf.GetEbpfManagerInstance(),
|
||||
relayedConnStore: make(map[uint16]net.Conn),
|
||||
turnConnStore: make(map[uint16]net.Conn),
|
||||
}
|
||||
return wgProxy
|
||||
}
|
||||
@@ -110,14 +110,14 @@ func (p *WGEBPFProxy) Listen() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRelayedConn add new relayed connection for the proxy
|
||||
func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) {
|
||||
wgEndpointPort, err := p.storeRelayedConn(relayedConn)
|
||||
// AddTurnConn add new turn connection for the proxy
|
||||
func (p *WGEBPFProxy) AddTurnConn(turnConn net.Conn) (*net.UDPAddr, error) {
|
||||
wgEndpointPort, err := p.storeTurnConn(turnConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort)
|
||||
log.Infof("turn conn added to wg proxy store: %s, endpoint port: :%d", turnConn.RemoteAddr(), wgEndpointPort)
|
||||
|
||||
wgEndpoint := &net.UDPAddr{
|
||||
IP: net.ParseIP(loopbackAddr),
|
||||
@@ -186,48 +186,48 @@ func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error {
|
||||
return fmt.Errorf("failed to read UDP packet from WG: %w", err)
|
||||
}
|
||||
|
||||
p.relayedConnMutex.Lock()
|
||||
conn, ok := p.relayedConnStore[uint16(addr.Port)]
|
||||
p.relayedConnMutex.Unlock()
|
||||
p.turnConnMutex.Lock()
|
||||
conn, ok := p.turnConnStore[uint16(addr.Port)]
|
||||
p.turnConnMutex.Unlock()
|
||||
if !ok {
|
||||
if p.ctx.Err() == nil {
|
||||
log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port)
|
||||
log.Debugf("turn conn not found by port because conn already has been closed: %d", addr.Port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := conn.Write(buf[:n]); err != nil {
|
||||
return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err)
|
||||
return fmt.Errorf("failed to forward local WG packet (%d) to remote turn conn: %w", addr.Port, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) {
|
||||
p.relayedConnMutex.Lock()
|
||||
defer p.relayedConnMutex.Unlock()
|
||||
func (p *WGEBPFProxy) storeTurnConn(turnConn net.Conn) (uint16, error) {
|
||||
p.turnConnMutex.Lock()
|
||||
defer p.turnConnMutex.Unlock()
|
||||
|
||||
np, err := p.nextFreePort()
|
||||
if err != nil {
|
||||
return np, err
|
||||
}
|
||||
p.relayedConnStore[np] = relayedConn
|
||||
p.turnConnStore[np] = turnConn
|
||||
return np, nil
|
||||
}
|
||||
|
||||
func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) {
|
||||
p.relayedConnMutex.Lock()
|
||||
defer p.relayedConnMutex.Unlock()
|
||||
func (p *WGEBPFProxy) removeTurnConn(turnConnID uint16) {
|
||||
p.turnConnMutex.Lock()
|
||||
defer p.turnConnMutex.Unlock()
|
||||
|
||||
_, ok := p.relayedConnStore[relayedConnID]
|
||||
_, ok := p.turnConnStore[turnConnID]
|
||||
if ok {
|
||||
log.Debugf("remove relayed conn from store by port: %d", relayedConnID)
|
||||
log.Debugf("remove turn conn from store by port: %d", turnConnID)
|
||||
}
|
||||
delete(p.relayedConnStore, relayedConnID)
|
||||
delete(p.turnConnStore, turnConnID)
|
||||
}
|
||||
|
||||
func (p *WGEBPFProxy) nextFreePort() (uint16, error) {
|
||||
if len(p.relayedConnStore) == 65535 {
|
||||
return 0, fmt.Errorf("reached maximum relayed connection numbers")
|
||||
if len(p.turnConnStore) == 65535 {
|
||||
return 0, fmt.Errorf("reached maximum turn connection numbers")
|
||||
}
|
||||
generatePort:
|
||||
if p.lastUsedPort == 65535 {
|
||||
@@ -236,7 +236,7 @@ generatePort:
|
||||
p.lastUsedPort++
|
||||
}
|
||||
|
||||
if _, ok := p.relayedConnStore[p.lastUsedPort]; ok {
|
||||
if _, ok := p.turnConnStore[p.lastUsedPort]; ok {
|
||||
goto generatePort
|
||||
}
|
||||
return p.lastUsedPort, nil
|
||||
|
||||
@@ -9,32 +9,32 @@ import (
|
||||
func TestWGEBPFProxy_connStore(t *testing.T) {
|
||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||
|
||||
p, _ := wgProxy.storeRelayedConn(nil)
|
||||
p, _ := wgProxy.storeTurnConn(nil)
|
||||
if p != 1 {
|
||||
t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort)
|
||||
}
|
||||
|
||||
numOfConns := 10
|
||||
for i := 0; i < numOfConns; i++ {
|
||||
p, _ = wgProxy.storeRelayedConn(nil)
|
||||
p, _ = wgProxy.storeTurnConn(nil)
|
||||
}
|
||||
if p != uint16(numOfConns)+1 {
|
||||
t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1)
|
||||
}
|
||||
if len(wgProxy.relayedConnStore) != numOfConns+1 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1)
|
||||
if len(wgProxy.turnConnStore) != numOfConns+1 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), numOfConns+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) {
|
||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||
|
||||
_, _ = wgProxy.storeRelayedConn(nil)
|
||||
_, _ = wgProxy.storeTurnConn(nil)
|
||||
wgProxy.lastUsedPort = 65535
|
||||
p, _ := wgProxy.storeRelayedConn(nil)
|
||||
p, _ := wgProxy.storeTurnConn(nil)
|
||||
|
||||
if len(wgProxy.relayedConnStore) != 2 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2)
|
||||
if len(wgProxy.turnConnStore) != 2 {
|
||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), 2)
|
||||
}
|
||||
|
||||
if p != 2 {
|
||||
@@ -46,11 +46,11 @@ func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) {
|
||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||
|
||||
for i := 0; i < 65535; i++ {
|
||||
_, _ = wgProxy.storeRelayedConn(nil)
|
||||
_, _ = wgProxy.storeTurnConn(nil)
|
||||
}
|
||||
|
||||
_, err := wgProxy.storeRelayedConn(nil)
|
||||
_, err := wgProxy.storeTurnConn(nil)
|
||||
if err == nil {
|
||||
t.Errorf("invalid relayed conn store calculation")
|
||||
t.Errorf("invalid turn conn store calculation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,10 +121,10 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn)
|
||||
func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add relayed conn: %w", err)
|
||||
return fmt.Errorf("add turn conn: %w", err)
|
||||
}
|
||||
|
||||
headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr)
|
||||
@@ -252,7 +252,7 @@ func (p *ProxyWrapper) CloseConn() error {
|
||||
}
|
||||
|
||||
func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
|
||||
defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port))
|
||||
defer p.wgeBPFProxy.removeTurnConn(uint16(p.wgRelayedEndpointAddr.Port))
|
||||
|
||||
buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead)
|
||||
for {
|
||||
@@ -273,7 +273,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Errorf("failed to write out relayed pkg to local conn: %v", err)
|
||||
log.Errorf("failed to write out turn pkg to local conn: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err
|
||||
}
|
||||
p.closeListener.Notify()
|
||||
if !errors.Is(err, io.EOF) {
|
||||
log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
|
||||
log.Errorf("failed to read from turn conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// Proxy is a transfer layer between the relayed connection and the WireGuard
|
||||
type Proxy interface {
|
||||
AddRelayedConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
|
||||
AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
|
||||
EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint
|
||||
Work() // Work start or resume the proxy
|
||||
Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works.
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestProxyCloseByRemoteConn(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892")
|
||||
relayedConn := newMockConn()
|
||||
err := tt.proxy.AddRelayedConn(ctx, addr, relayedConn)
|
||||
err := tt.proxy.AddTurnConn(ctx, addr, relayedConn)
|
||||
if err != nil {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func redirectTraffic(t *testing.T, proxy Proxy, wgPort int, endPointAddr *net.UD
|
||||
_ = relayedServer.Close()
|
||||
}()
|
||||
|
||||
if err := proxy.AddRelayedConn(context.Background(), endPointAddr, relayedConn); err != nil {
|
||||
if err := proxy.AddTurnConn(context.Background(), endPointAddr, relayedConn); err != nil {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
|
||||
@@ -119,9 +119,9 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
|
||||
}
|
||||
defer relayConn.Close()
|
||||
|
||||
// Add relayed connection to proxy
|
||||
if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add relayed connection: %v", err)
|
||||
// Add TURN connection to proxy
|
||||
if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add TURN connection: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := proxy.CloseConn(); err != nil {
|
||||
@@ -304,8 +304,8 @@ func TestRedirectAs_Multiple_Switches(t *testing.T) {
|
||||
Port: 38746,
|
||||
}
|
||||
|
||||
if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add relayed connection: %v", err)
|
||||
if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
|
||||
t.Fatalf("failed to add TURN connection: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := proxy.CloseConn(); err != nil {
|
||||
|
||||
@@ -51,12 +51,12 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy {
|
||||
return p
|
||||
}
|
||||
|
||||
// AddRelayedConn dials the local WireGuard port and stores the relayed connection.
|
||||
// AddTurnConn
|
||||
// The provided Context must be non-nil. If the context expires before
|
||||
// the connection is complete, an error is returned. Once successfully
|
||||
// connected, any expiration of the context will not affect the
|
||||
// connection.
|
||||
func (p *WGUDPProxy) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
func (p *WGUDPProxy) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||
dialer := net.Dialer{}
|
||||
localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort))
|
||||
if err != nil {
|
||||
|
||||
@@ -35,8 +35,6 @@ var (
|
||||
// exported so a diagnostic reader reports the same locations that are written.
|
||||
const (
|
||||
// NRPTKeyPrefix starts the name of every NRPT rule key this client creates.
|
||||
// Older versions used different layouts under the same prefix: a single
|
||||
// unsuffixed key, then one key per domain, now one key per batch of domains.
|
||||
NRPTKeyPrefix = "NetBird-Match"
|
||||
|
||||
// DNSPolicyConfigRoot holds the NRPT rules of the local policy store.
|
||||
@@ -91,6 +89,7 @@ type registryConfigurator struct {
|
||||
guid string
|
||||
routingAll bool
|
||||
gpo bool
|
||||
nrptEntryCount int
|
||||
origNameservers []netip.Addr
|
||||
}
|
||||
|
||||
@@ -323,9 +322,14 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
|
||||
}
|
||||
|
||||
if len(matchDomains) != 0 {
|
||||
if err := r.addDNSMatchPolicy(matchDomains, config.ServerIP); err != nil {
|
||||
count, err := r.addDNSMatchPolicy(matchDomains, config.ServerIP)
|
||||
// Update count even on error to ensure cleanup covers partially created rules
|
||||
r.nrptEntryCount = count
|
||||
if err != nil {
|
||||
return fmt.Errorf("add dns match policy: %w", err)
|
||||
}
|
||||
} else {
|
||||
r.nrptEntryCount = 0
|
||||
}
|
||||
|
||||
r.updateState(stateManager)
|
||||
@@ -341,8 +345,9 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
|
||||
|
||||
func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) {
|
||||
if err := stateManager.UpdateState(&ShutdownState{
|
||||
Guid: r.guid,
|
||||
GPO: r.gpo,
|
||||
Guid: r.guid,
|
||||
GPO: r.gpo,
|
||||
NRPTEntryCount: r.nrptEntryCount,
|
||||
}); err != nil {
|
||||
log.Errorf("failed to update shutdown state: %s", err)
|
||||
}
|
||||
@@ -357,7 +362,7 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) error {
|
||||
func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, error) {
|
||||
// if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored
|
||||
// see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745
|
||||
|
||||
@@ -374,17 +379,19 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
|
||||
gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex)
|
||||
|
||||
if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil {
|
||||
return fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err)
|
||||
return ruleIndex, fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err)
|
||||
}
|
||||
|
||||
// Increment immediately so the caller's cleanup path knows about this rule
|
||||
ruleIndex++
|
||||
|
||||
if r.gpo {
|
||||
if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil {
|
||||
return fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex, err)
|
||||
return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("added NRPT rule %d with %d domains", ruleIndex, len(batchDomains))
|
||||
ruleIndex++
|
||||
log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains))
|
||||
}
|
||||
|
||||
if r.gpo {
|
||||
@@ -394,7 +401,7 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
|
||||
}
|
||||
|
||||
log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains))
|
||||
return nil
|
||||
return ruleIndex, nil
|
||||
}
|
||||
|
||||
func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error {
|
||||
@@ -527,28 +534,28 @@ func (r *registryConfigurator) restoreHostDNS() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeDNSMatchPolicies deletes every NRPT rule this client may have created,
|
||||
// from the local and the GPO policy store. The rules are found by enumerating
|
||||
// the registry, the only authoritative record of what was written. Cleanup must
|
||||
// not depend on a rule count: the in-memory one is scoped to a single
|
||||
// registryConfigurator and the persisted one is deleted on every clean
|
||||
// disconnect, and a rule left behind keeps resolving names over an interface
|
||||
// that is gone, until reboot discards the volatile key.
|
||||
func (r *registryConfigurator) removeDNSMatchPolicies() error {
|
||||
var merr *multierror.Error
|
||||
|
||||
for _, root := range []string{DNSPolicyConfigRoot, GPODNSPolicyConfigRoot} {
|
||||
names, err := listNRPTRuleKeys(root)
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("list rule keys under %s: %w", root, err))
|
||||
continue
|
||||
// Try to remove the base entries (for backward compatibility)
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove local base entry: %w", err))
|
||||
}
|
||||
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(gpoDnsPolicyConfigMatchPath); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove GPO base entry: %w", err))
|
||||
}
|
||||
|
||||
for i := 0; i < r.nrptEntryCount; i++ {
|
||||
localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)
|
||||
gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i)
|
||||
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(localPath); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove local entry %d: %w", i, err))
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
path := root + `\` + name
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(path); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove entry %s: %w", path, err))
|
||||
}
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(gpoPath); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove GPO entry %d: %w", i, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,39 +570,6 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error {
|
||||
return r.restoreHostDNS()
|
||||
}
|
||||
|
||||
// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store
|
||||
// root. An absent root holds nothing to clean up, which is the normal state of
|
||||
// the GPO store on a machine without DNS Client policy.
|
||||
func listNRPTRuleKeys(root string) ([]string, error) {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS)
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
|
||||
// the GPO store is absent on a machine without DNS client policy
|
||||
log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", root)
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
// any other failure has to reach the caller: reporting no rules would
|
||||
// report a successful cleanup while leaving the rules in place
|
||||
return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err)
|
||||
}
|
||||
defer closer(k)
|
||||
|
||||
names, err := k.ReadSubKeyNames(-1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read subkey names: %w", err)
|
||||
}
|
||||
|
||||
var ruleKeys []string
|
||||
for _, name := range names {
|
||||
// registry key names are case insensitive
|
||||
if strings.HasPrefix(strings.ToLower(name), strings.ToLower(NRPTKeyPrefix)) {
|
||||
ruleKeys = append(ruleKeys, name)
|
||||
}
|
||||
}
|
||||
|
||||
return ruleKeys, nil
|
||||
}
|
||||
|
||||
func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
||||
|
||||
// Create a test interface registry key so updateSearchDomains doesn't fail
|
||||
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||
interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID
|
||||
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create test interface registry key")
|
||||
testKey.Close()
|
||||
@@ -56,7 +56,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify 3 NRPT rules exist
|
||||
assert.Equal(t, 3, countNRPTRuleKeys(t), "Should create 3 NRPT rules for 125 domains")
|
||||
assert.Equal(t, 3, cfg.nrptEntryCount, "Should create 3 NRPT rules for 125 domains")
|
||||
for i := 0; i < 3; i++ {
|
||||
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
|
||||
require.NoError(t, err)
|
||||
@@ -81,7 +81,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify first 2 NRPT rules exist
|
||||
assert.Equal(t, 2, countNRPTRuleKeys(t), "Should create 2 NRPT rules for 75 domains")
|
||||
assert.Equal(t, 2, cfg.nrptEntryCount, "Should create 2 NRPT rules for 75 domains")
|
||||
for i := 0; i < 2; i++ {
|
||||
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
|
||||
require.NoError(t, err)
|
||||
@@ -106,65 +106,9 @@ func registryKeyExists(path string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TestNRPTCleanupWithoutRuleCount verifies that rules written by a previous run
|
||||
// are removed by a configurator that has no record of how many there are: an
|
||||
// unclean exit loses the in-memory count and a clean disconnect deletes the
|
||||
// persisted one, so cleanup cannot depend on either.
|
||||
func TestNRPTCleanupWithoutRuleCount(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping registry integration test in short mode")
|
||||
}
|
||||
|
||||
defer cleanupRegistryKeys(t)
|
||||
cleanupRegistryKeys(t)
|
||||
|
||||
testIP := netip.MustParseAddr("100.64.0.1")
|
||||
|
||||
// 75 domains produce two indexed rules, as the current layout does
|
||||
domains := make([]string, 75)
|
||||
for i := range domains {
|
||||
domains[i] = fmt.Sprintf(".domain%d.com", i+1)
|
||||
}
|
||||
|
||||
previousRun := ®istryConfigurator{}
|
||||
require.NoError(t, previousRun.addDNSMatchPolicy(domains, testIP))
|
||||
|
||||
// the unsuffixed key an older version would have written
|
||||
require.NoError(t, previousRun.configureDNSPolicy(dnsPolicyConfigMatchPath, []string{".legacy.example.com"}, testIP))
|
||||
|
||||
// a policy owned by someone else, which cleanup must not touch
|
||||
foreignPath := DNSPolicyConfigRoot + `\DnsPolicyConfigTestForeign`
|
||||
foreignKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, foreignPath, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create foreign policy key")
|
||||
foreignKey.Close()
|
||||
defer func() {
|
||||
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignPath)
|
||||
}()
|
||||
|
||||
require.Equal(t, 3, countNRPTRuleKeys(t), "Should have two indexed rules and the legacy one")
|
||||
|
||||
// a configurator that never applied a DNS config, as one built after a
|
||||
// restart or from a shutdown state without a count is
|
||||
freshRun := ®istryConfigurator{}
|
||||
require.NoError(t, freshRun.removeDNSMatchPolicies())
|
||||
|
||||
assert.Equal(t, 0, countNRPTRuleKeys(t), "Should remove every rule left by the previous run")
|
||||
|
||||
exists, err := registryKeyExists(foreignPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Should not remove a policy that is not ours")
|
||||
}
|
||||
|
||||
func countNRPTRuleKeys(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
names, err := listNRPTRuleKeys(DNSPolicyConfigRoot)
|
||||
require.NoError(t, err, "Should list NRPT rule keys")
|
||||
return len(names)
|
||||
}
|
||||
|
||||
func cleanupRegistryKeys(*testing.T) {
|
||||
cfg := ®istryConfigurator{}
|
||||
// Clean up more entries to account for batching tests with many domains
|
||||
cfg := ®istryConfigurator{nrptEntryCount: 20}
|
||||
_ = cfg.removeDNSMatchPolicies()
|
||||
}
|
||||
|
||||
@@ -181,7 +125,7 @@ func TestNRPTDomainBatching(t *testing.T) {
|
||||
|
||||
// Create a test interface registry key so updateSearchDomains doesn't fail
|
||||
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||
interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID
|
||||
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||
require.NoError(t, err, "Should create test interface registry key")
|
||||
testKey.Close()
|
||||
@@ -249,7 +193,7 @@ func TestNRPTDomainBatching(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify that exactly expectedRuleCount rules were created
|
||||
assert.Equal(t, tc.expectedRuleCount, countNRPTRuleKeys(t),
|
||||
assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount,
|
||||
"Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount)
|
||||
|
||||
// Verify all expected rules exist
|
||||
|
||||
@@ -5,8 +5,9 @@ import (
|
||||
)
|
||||
|
||||
type ShutdownState struct {
|
||||
Guid string
|
||||
GPO bool
|
||||
Guid string
|
||||
GPO bool
|
||||
NRPTEntryCount int
|
||||
}
|
||||
|
||||
func (s *ShutdownState) Name() string {
|
||||
@@ -15,8 +16,9 @@ func (s *ShutdownState) Name() string {
|
||||
|
||||
func (s *ShutdownState) Cleanup() error {
|
||||
manager := ®istryConfigurator{
|
||||
guid: s.Guid,
|
||||
gpo: s.GPO,
|
||||
guid: s.Guid,
|
||||
gpo: s.GPO,
|
||||
nrptEntryCount: s.NRPTEntryCount,
|
||||
}
|
||||
|
||||
if err := manager.restoreUncleanShutdownDNS(); err != nil {
|
||||
|
||||
@@ -2,21 +2,17 @@ package ebpf
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/cilium/ebpf/link"
|
||||
"github.com/cilium/ebpf/rlimit"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
|
||||
)
|
||||
|
||||
const (
|
||||
xdpProgName = "nb_xdp_prog"
|
||||
|
||||
mapKeyFeatures uint32 = 0
|
||||
|
||||
featureFlagWGProxy = 0b00000001
|
||||
@@ -72,50 +68,21 @@ func (tf *GeneralManager) loadXdp() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// lo has no native XDP, so the program runs in generic mode. Unless it
|
||||
// declares multi-buffer support the kernel must linearize every non-linear
|
||||
// skb before running it. Loopback packets are up to 64 KB, so that is a
|
||||
// contiguous GFP_ATOMIC allocation per packet, and when it fails the packet
|
||||
// is dropped before the program runs, stalling local TCP connections.
|
||||
// Multi-buffer XDP in generic mode requires kernel 6.3, so fall back to a
|
||||
// plain attach when the kernel rejects it.
|
||||
err = tf.attachXdp(iFace.Index, true)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
log.Debugf("failed to attach multi-buffer xdp program, retrying without it: %s", err)
|
||||
|
||||
return tf.attachXdp(iFace.Index, false)
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) attachXdp(iFaceIndex int, multiBuffer bool) error {
|
||||
spec, err := loadBpf()
|
||||
// load pre-compiled programs into the kernel.
|
||||
err = loadBpfObjects(&tf.bpfObjs, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load bpf spec: %w", err)
|
||||
}
|
||||
|
||||
if multiBuffer {
|
||||
prog, ok := spec.Programs[xdpProgName]
|
||||
if !ok {
|
||||
return fmt.Errorf("program %s not found in bpf spec", xdpProgName)
|
||||
}
|
||||
prog.Flags |= unix.BPF_F_XDP_HAS_FRAGS
|
||||
}
|
||||
|
||||
if err := spec.LoadAndAssign(&tf.bpfObjs, nil); err != nil {
|
||||
return fmt.Errorf("load bpf objects: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
tf.link, err = link.AttachXDP(link.XDPOptions{
|
||||
Program: tf.bpfObjs.NbXdpProg,
|
||||
Interface: iFaceIndex,
|
||||
Interface: iFace.Index,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if closeErr := tf.bpfObjs.Close(); closeErr != nil {
|
||||
log.Debugf("failed to close bpf objects after xdp attach error: %s", closeErr)
|
||||
}
|
||||
_ = tf.bpfObjs.Close()
|
||||
tf.link = nil
|
||||
return fmt.Errorf("attach xdp: %w", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
|
||||
conn.dumpState.NewLocalProxy()
|
||||
wgProxy, err = conn.newProxy(iceConnInfo.RemoteConn)
|
||||
if err != nil {
|
||||
conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err)
|
||||
conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err)
|
||||
return
|
||||
}
|
||||
ep = wgProxy.EndpointAddr()
|
||||
@@ -878,8 +878,9 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) {
|
||||
}
|
||||
|
||||
wgProxy := conn.config.WgConfig.WgInterface.GetProxy()
|
||||
if err := wgProxy.AddRelayedConn(conn.ctx, udpAddr, remoteConn); err != nil {
|
||||
return nil, fmt.Errorf("add relayed conn to proxy: %w", err)
|
||||
if err := wgProxy.AddTurnConn(conn.ctx, udpAddr, remoteConn); err != nil {
|
||||
conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return wgProxy, nil
|
||||
}
|
||||
|
||||
@@ -255,8 +255,8 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent
|
||||
return
|
||||
}
|
||||
|
||||
w.log.Debugf("agent dial")
|
||||
remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer)
|
||||
w.log.Debugf("turn agent dial")
|
||||
remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer)
|
||||
if err != nil {
|
||||
w.log.Debugf("failed to dial the remote peer: %s", err)
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
@@ -517,8 +517,8 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia
|
||||
w.logSuccessfulPaths(agent)
|
||||
return
|
||||
case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed:
|
||||
// ice.ConnectionStateClosed happens when we recreate the agent. The P2P to relay switch requires
|
||||
// notifying conn.onICEStateDisconnected so it can update the currently used priority.
|
||||
// ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to
|
||||
// notify the conn.onICEStateDisconnected changes to update the current used priority
|
||||
|
||||
sessionChanged := w.closeAgent(agent, dialerCancel)
|
||||
|
||||
@@ -532,7 +532,7 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
|
||||
func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) {
|
||||
if isController(w.config) {
|
||||
return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd)
|
||||
} else {
|
||||
|
||||
@@ -115,7 +115,7 @@ sequenceDiagram
|
||||
Resp->>Resp: parse usage tokens, completion
|
||||
Note over Resp: capture_completion gates raw<br/>completion capture
|
||||
Resp->>Cost: tokens
|
||||
Cost->>Cost: lookup rates from config-delivered<br/>pricing table + compute cost
|
||||
Cost->>Cost: lookup pricing.yaml + compute cost
|
||||
Cost->>Rec: tokens + cost
|
||||
Rec->>MgmtGrpc: RecordLLMUsage(provider, model, prompt_t, completion_t, cost, groups, user)
|
||||
Rec-->>Log: emit access-log entry<br/>(if EnableLogCollection)
|
||||
|
||||
@@ -15,10 +15,6 @@ Inside the package: `manager.go` is the CRUD + permissions-gated facade; `synthe
|
||||
| ---- | ---- |
|
||||
| `agentnetwork/manager.go` | Manager interface + CRUD + permission gates + bootstrap-settings + reconcile trigger |
|
||||
| `agentnetwork/synthesizer.go` | Settings/policy → wire-format synthesis; sole writer of the proxy middleware chain |
|
||||
| `agentnetwork/synthesizer_pricing.go` | `buildCostMeterConfigJSON` — default table + per-provider prices → `cost_meter` config |
|
||||
| `agentnetwork/pricing/defaults.go` | Default pricing table derived from the catalog + supplementals; `DefaultTable`, `LookupDefault`, wire `Entry` |
|
||||
| `agentnetwork/pricing/override.go` | `LoadFile`/`StartReloader` for `AgentNetwork.PricingDefaultsFile` (mtime poll, merge over compiled-in base) |
|
||||
| `agentnetwork/pricing/{exampleyaml,gen}.go` | Generates `defaults_llm_pricing.example.yaml` from the compiled-in table (golden-tested) |
|
||||
| `agentnetwork/policyselect.go` | Per-request policy attribution + account-budget ceiling (min-wins) |
|
||||
| `agentnetwork/reconcile.go` | Per-account synth diff vs in-memory cache → Create/Update/Delete |
|
||||
| `agentnetwork/catalog/catalog.go` | Static provider catalogue (auth headers, identity-injection shapes) |
|
||||
@@ -52,8 +48,6 @@ flowchart TD
|
||||
I --> J[indexProviderGroups: providerID -> sorted source groups]
|
||||
J --> K[buildRouterConfigJSON drops orphan providers]
|
||||
J --> L[buildIdentityInjectConfigJSON per catalog entry]
|
||||
J --> K2[buildCostMeterConfigJSON: default table + per-provider prices]
|
||||
K2 --> P
|
||||
H --> M[mergeGuardrails: union allowlist, OR redact]
|
||||
M --> N[applyAccountCollectionControls account toggle = SOLE capture control]
|
||||
N --> O[marshalGuardrailConfig]
|
||||
@@ -66,84 +60,6 @@ flowchart TD
|
||||
R --> T[accountManager.UpdateAccountPeers — fans synth ACLs into network map]
|
||||
```
|
||||
|
||||
### LLM pricing (management is the sole authority)
|
||||
|
||||
**The proxy carries no price list.** Management synthesizes the entire pricing
|
||||
table and ships it inside `cost_meter`'s `ConfigJSON`, so a price change reaches
|
||||
the proxies as an ordinary mapping push — the chain rebuild installs a fresh
|
||||
table and there is nothing to reload on the proxy side.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[catalog.All — PricingSurfaces x Models] --> B[buildDefaultTable + supplementalDefaults]
|
||||
B --> C{AgentNetwork.PricingDefaultsFile}
|
||||
C -- absent --> D[compiled-in table serves]
|
||||
C -- loaded --> E[LoadFile: merge file entries WHOLE over compiled base]
|
||||
E --> F[mergedTable atomic.Pointer]
|
||||
D --> G[DefaultTable]
|
||||
F --> G
|
||||
G --> H[buildCostMeterConfigJSON — pricing.defaults]
|
||||
I[types.Provider.Models operator prices] --> J[normalizePricingModelID<br/>bedrock ARN/region/version, vertex @version]
|
||||
J --> K[materializeEntry: default entry as base,<br/>operator input/output verbatim,<br/>cache pointers only when non-nil]
|
||||
K --> L[pricing.providers keyed by provider record ID]
|
||||
H --> M[cost_meter ConfigJSON]
|
||||
L --> M
|
||||
G --> N[GET /catalog — applyDefaultPricing prefills dashboard rows]
|
||||
O[StartReloader: mtime poll every ReloadInterval 1m] --> E
|
||||
```
|
||||
|
||||
**Two tiers, resolved per request on the proxy** (`synthesizer_pricing.go:22-35`):
|
||||
|
||||
- `pricing.defaults` — surface (`openai`/`anthropic`/`bedrock`) → normalized model
|
||||
id → rates. The **full** default table ships to every account: it is small
|
||||
(~10 KB) and it is what keeps gateway-style providers (which enumerate no
|
||||
models, so they claim every model) priced.
|
||||
- `pricing.providers` — provider **record** id → normalized model id → rates,
|
||||
matched against the `llm.resolved_provider_id` the router stamps. Entries are
|
||||
**fully materialized here**, at synth time: `materializeEntry` starts from the
|
||||
default entry for that model so cache rates the operator didn't state are
|
||||
inherited, overlays operator `input`/`output` verbatim (**including an explicit
|
||||
0**, which prices a self-hosted or internal endpoint as free rather than
|
||||
silently reverting to list price), and overlays cache-rate **pointers only when
|
||||
non-nil** — `nil` means "inherit the default", an explicit `0` means "no
|
||||
discount, bill this bucket at the input rate". The proxy therefore does two map
|
||||
lookups and no merging.
|
||||
|
||||
Same orphan rule as the router: a provider no enabled policy authorises is
|
||||
unreachable, so its prices aren't shipped. Model ids are normalized with the
|
||||
**same** functions the request parser uses (`NormalizeBedrockModel` /
|
||||
`NormalizeVertexModel`), which is what makes the per-record lookup key compare
|
||||
equal to the `llm.model` the proxy meters. Post-normalization duplicates resolve
|
||||
first-occurrence-wins, matching the routing dedup order.
|
||||
|
||||
**`AgentNetwork.PricingDefaultsFile`** (`config.go:190-207`) lets an operator
|
||||
replace default rates without a rebuild. Schema is `surface → model → rates`
|
||||
(`input_per_1k`, `output_per_1k`, and optional `cached_input_per_1k` /
|
||||
`cache_read_per_1k` / `cache_creation_per_1k`). Semantics:
|
||||
|
||||
- A **relative** path resolves against `<Datadir>`, so a bare filename lands
|
||||
alongside the store. Empty config probes `<Datadir>/defaults_llm_pricing.yaml`.
|
||||
- An **explicitly configured** path is *required to load*: a typo or malformed
|
||||
file fails startup, because the operator believes those rates are live. The
|
||||
conventional probe is optional — an absent file just serves compiled-in
|
||||
defaults, and the path stays watched in case it appears later.
|
||||
- File entries **replace** the compiled-in entry for the same (surface, model)
|
||||
**whole** — they are not field-merged, so an entry must repeat the cache rates
|
||||
it wants to keep. Everything the file doesn't mention keeps built-in rates.
|
||||
- Unknown YAML fields are rejected (`KnownFields(true)`) and every rate must be
|
||||
finite and non-negative — the same constraints the HTTP API enforces on
|
||||
operator per-provider prices.
|
||||
- Reload is an mtime poll (`ReloadInterval`, 1 min) and is **lenient at runtime**:
|
||||
a parse error keeps the previous table, a deleted file reverts to compiled-in
|
||||
defaults. A mid-edit save can never take pricing down.
|
||||
|
||||
The live table feeds **both** consumers, which is what keeps them consistent: the
|
||||
synthesizer (what proxies actually bill with) and `GET /api/agent-network/catalog`
|
||||
via `applyDefaultPricing` (what the dashboard's model-row prices prefill with).
|
||||
`defaults_llm_pricing.example.yaml` is generated from the compiled-in table
|
||||
(`go generate ./management/internals/modules/agentnetwork/pricing`) and
|
||||
golden-tested, so operators start from a file matching the built-in rates exactly.
|
||||
|
||||
### Budget rule resolution (min-wins, group+user bound)
|
||||
|
||||
```mermaid
|
||||
@@ -208,7 +124,7 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
||||
| on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** |
|
||||
| on_request | 4 | `llm_guardrail` | `{"provider_allowlists"?: {providerID: []model}, "prompt_capture":{enabled,redact_pii}}` | – |
|
||||
| on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – |
|
||||
| on_response | 6 | `cost_meter` | `{"pricing":{"defaults":{surface:{model:rates}},"providers"?:{providerRecordID:{model:rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}` | – |
|
||||
| on_response | 6 | `cost_meter` | `{}` | – |
|
||||
| on_response | 7 | `llm_response_parser` | `{"capture_completion": <bool>, "redact_pii"?: true}` | – |
|
||||
- **Synthesized service shape** (`synthesizer.go:739`): `Mode=HTTP`, `Private=true`, `Domain=<subdomain>.<cluster>`, `AccessGroups=unionSourceGroups(enabledPolicies)`, one `TargetTypeCluster` target with `Host=noop.invalid:443` (router rewrites per request), `Options.{DirectUpstream,AgentNetwork}=true`, `DisableAccessLog=!settings.EnableLogCollection`, `CaptureMax{Req,Resp}Bytes=1<<20`, `CaptureContentTypes=["application/json","text/event-stream"]`.
|
||||
|
||||
@@ -223,12 +139,6 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
||||
- **Orphan providers (no enabled policy authorises them) NEVER reach the router** (`synthesizer.go:351-357`); skipped from `identity_inject` for symmetry.
|
||||
- **Provider creation refuses empty `api_key`** (`manager.go:175`); **deletion refuses while any policy still references it** (`manager.go:265-273`).
|
||||
- **Session keypair stability across provider edits** (`manager.go:226-228`) — server-managed, copied through every `UpdateProvider`, never API-surfaced.
|
||||
- **Management is the sole pricing authority.** The proxy has no embedded price list, so an account whose `cost_meter` config carries no `pricing` block bills **nothing** (`cost.skipped=unknown_model`, $0) rather than falling back to stale built-ins. The top-level `pricing` wrapper is also the feature-detection signal in both directions: an old proxy ignores it as an unknown field, and a new proxy reads its absence as "old management".
|
||||
- **Per-provider prices are materialized at synth time, not merged on the proxy** (`synthesizer_pricing.go:114-131`). A per-record entry is always complete, so the proxy's lookup is per-record-then-defaults with no field-level fallback between tiers.
|
||||
- **An explicit operator price of `0` prices the model as free** — it must not be treated as "unset" and reverted to list price (`synthesizer_pricing.go:49-54`). Only *cache*-rate fields distinguish unset from zero, via `*float64`.
|
||||
- **Pricing model ids are normalized with the same functions the request parser uses** (`normalizePricingModelID`). If the two ever diverge, per-record prices silently stop matching and every request falls through to surface defaults.
|
||||
- **The default table's coverage is structural, not curated.** It is derived from the catalog via each provider's `PricingSurfaces`; `TestDefaultTable_CoversEveryCatalogModel` fails on an unpriced catalog model and `TestDefaultTable_NoConflictingContributions` fails if two providers contribute the same (surface, model) at different rates.
|
||||
- **A pricing-defaults file failure is fatal only at startup, and only for an explicitly configured path.** Runtime reload failures keep the previous table; a deleted file reverts to compiled-in defaults (`pricing/override.go:62-81, 113-148`).
|
||||
|
||||
## Things to scrutinize
|
||||
|
||||
@@ -266,12 +176,10 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
||||
- **Capture-pointer semantics (restated):** non-agent-network callers see no field → legacy nil-default emit, identical to pre-PR. Agent-network targets always carry an explicit `capture_*` value.
|
||||
- **`TestSynthesizeServices_HappyPath` was updated:** request-parser config moved from `{}` to `{"capture_prompt":false}` (`synthesizer_test.go:174`). External snapshot tests against synth output need updating.
|
||||
- **`MergedGuardrails` retains zeroed `TokenLimits`/`Budget`/`Retention`** even though `Policy.Limits` carries the real values now; `llm_limit_check` is the authoritative enforcement. Comment at `synthesizer.go:940-948` calls this out.
|
||||
- **`cost_meter`'s `pricing` block is version-skew-safe in both directions.** A proxy predating config-delivered pricing ignores the field as unknown JSON (it previously priced from its own embedded table, so it keeps billing — at its own rates, which is the skew to be aware of during a rolling upgrade). A current proxy paired with old management sees no `pricing` block, logs one warning at chain-build time, and records `cost.skipped=unknown_model` — token counting and cap enforcement are unaffected, only the USD annotation goes to $0.
|
||||
|
||||
### Performance
|
||||
|
||||
- **`SynthesizeServices` runs on every controller tick / mutation reconcile.** Cost: 4 store reads + optional per-provider keypair backfill. Sort + index + merge are O(N log N) / O(P × G); dominant cost is JSON marshalling. No nested loops escape these dimensions.
|
||||
- **The full default pricing table is marshalled into every account's `cost_meter` config on every synth** (~10 KB serialized). This is a deliberate trade: it keeps gateway-style providers priced for every catalog model, and it is the largest single contributor to the synth JSON. `DefaultTable()` itself is a pointer load (or a `sync.Once`-built map) — the cost is the marshal, not the build.
|
||||
- **`reconcile.diffMappings` is O(N + M)** with N=M=1 per account today — effectively constant.
|
||||
- **`SynthesizeServicesForCluster`** (`synthesizer.go:71`) walks every account on a cluster; per-account failures are **swallowed** (`synthesizer.go:91-93`) so a single misconfigured account doesn't drop the cluster. Runs per proxy reconnect.
|
||||
|
||||
@@ -280,7 +188,6 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
||||
- **Activity codes:** `AgentNetwork{Provider,Policy,Guardrail,BudgetRule}{Created,Updated,Deleted}`; `AgentNetworkSettingsUpdated` with `log_collection/prompt_collection/redact_pii` payload (`manager.go:567-571`). **No activity code for `SelectPolicyForRequest` denies** — surfaced via proxy access log only (likely intentional given volume).
|
||||
- **Deny codes** namespaced: `llm_policy.{token,budget}_cap_exceeded`, `llm_account.{token,budget}_cap_exceeded` (`policyselect.go:18-26`).
|
||||
- **Reconcile failures are logged at warn and swallowed** (`reconcile.go:42-44`). Persistent synth failures (e.g. unknown catalog id) silently keep the proxy out of sync — consider a manager-level synth-health surface if this becomes a support burden.
|
||||
- **Pricing-file lifecycle logs at info** (load, reload, revert-to-built-ins) and **at warn** for a runtime reload failure; the mtime check itself is `Debugf`. There is no metric on reload failures, so an operator who breaks the file mid-flight keeps billing at the previous table with only a log line to show it (`pricing/override.go:113-148`).
|
||||
|
||||
## Test coverage
|
||||
|
||||
@@ -291,9 +198,6 @@ At request time the path is independent: the proxy calls `SelectPolicyForRequest
|
||||
| `synthesizer_guardrail_realstore_test.go` | `PromptCaptureAccountIsSoleControl`; `PromptCaptureFlowsWhenAccountOptsIn`; `AccountRedactWithoutGuardrailRedact`; `NoGuardrail_CaptureOff`. |
|
||||
| `synthesizer_log_collection_realstore_test.go` | `LogCollection{Off_SuppressesAccessLog,On_PermitsAccessLog}` — verifies `DisableAccessLog` propagation through `ToProtoMapping`. |
|
||||
| `synthesizer_parser_redact_realstore_test.go` | **Capture-pointer regression suite:** `ParserConfigsCarryRedactPii`; `ParserConfigsSuppressCaptureWhenLogCollectionOnly` (log=on/prompt=off ⇒ both capture flags false); `ParserConfigsOmitRedactPiiWhenOff`. |
|
||||
| `synthesizer_pricing_test.go` | `BuildCostMeterConfig_{BedrockModelNormalization,CacheRateNilVsZero,OrphanAndGatewayProviders}` — the per-record tier's three load-bearing rules: keys normalized like the parser's, `nil` cache pointer inherits vs explicit `0` bills at input rate, and orphan / gateway (empty `Models`) providers ship no per-record entry. |
|
||||
| `pricing/defaults_test.go` | `DefaultTable_{CoversEveryCatalogModel,NoConflictingContributions,AllRatesFiniteNonNegative,PinnedRates}`; `LookupDefault_SurfaceOrder`. Catalog-derived coverage + rate sanity are structural, not curated. |
|
||||
| `pricing/override_test.go` | `LoadFile_{MergesOverCompiledDefaults,MissingPath,RejectsInvalid}`; `Reload_LifeCycle` (mtime detect, parse error keeps previous, delete reverts to built-ins); `ExampleYAML_InSyncWithBuiltins` golden. |
|
||||
| `policyselect_test.go` | Mock-store: `NoApplicablePolicies`; `AllowWithLowestGroupAttribution`; `LargerPoolWinsAcrossUsageLevels`; `StaysOnLargerPoolAfterPartialDrain`; `FallsThroughToSmallerPoolWhenLargerExhausted`; `TiebreakBy{LargerGroupPool,CreatedAt}`; `DeniesWhenAllExhausted`; `UncappedPolicyAlwaysWinsAgainstCapped`; `DisabledPolicyIgnored`; `StoreErrorPropagates`; `RejectsEmptyAccount`; `SharesGroupCounterAcrossPolicies`; `AntiFallThroughOnLowestGroup`; `BudgetOnlyExhaustionDenies`; `BudgetTighterThanTokenWins`. |
|
||||
| `policyselect_realstore_test.go` | Real-sqlite regression guard: `NoApplicablePolicies`; `AllowAndLowestGroupAttribution`; `LargerPoolWins_FallsThroughWhenExhausted`; `BudgetCapDenies`; `GroupCounterSharedAcrossPolicies`; `DisabledPolicyIgnored`. |
|
||||
| `policyselect_account_realstore_test.go` | Account budget rules: `AccountCeilingBindsEvenWithUncappedPolicy` (min-wins); `AccountGroupCeiling`; `AccountTargetUsersBindsOnlyThatUser`; `AccountRuleRecordsToOwnWindow`. |
|
||||
|
||||
@@ -5,7 +5,7 @@ LLM request. The two highest-blast-radius areas are the **capture-pointer
|
||||
semantics** and the **limit_check ⇒ limit_record** record-once invariant.
|
||||
|
||||
Sibling module: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — the SDK
|
||||
adapters + pricing table and cost formula this chain delegates to.
|
||||
adapters + pricing catalog this chain delegates to.
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +34,7 @@ rewrites.
|
||||
| `llm_identity_inject` | OnRequest | `llm.{resolved_provider_id,authorising_groups}`, `Input.{UserEmail,UserID,UserGroups,UserGroupNames}` | none | header strip/inject + optional body rewrite |
|
||||
| `llm_guardrail` | OnRequest | `llm.{model,request_prompt_raw}` | `llm_policy.{decision,reason}`, `llm.request_prompt` | none (model allowlist deny) |
|
||||
| `llm_response_parser` | OnResponse | `llm.provider`, `Input.{RespHeaders,RespBody,Status}` | `llm.{input,output,total,cached_input,cache_creation}_tokens`, `llm.response_completion` | none |
|
||||
| `cost_meter` | OnResponse | `llm.{provider,model,resolved_provider_id}`, token buckets | `cost.usd_{input,cached_input,cache_creation,output,total,cache}` or `cost.skipped` | none (in-memory pricing lookup) |
|
||||
| `cost_meter` | OnResponse | `llm.{provider,model}`, token buckets | `cost.usd_total` or `cost.skipped` | pricing lookup |
|
||||
| `llm_limit_record` | OnResponse | `llm.{attribution_group_id,attribution_window_seconds,input_tokens,output_tokens}`, `cost.usd_total` | none | gRPC `RecordLLMUsage` |
|
||||
|
||||
[all_test.go:26–40](../../../proxy/internal/middleware/builtin/all_test.go)
|
||||
@@ -44,7 +44,7 @@ locks the ID set; adding or removing one is a conscious extension.
|
||||
|
||||
| File | LOC | Notes |
|
||||
|---|---:|---|
|
||||
| `builtin.go` | 90 | Registry + `FactoryContext` (ctx, meter, logger, mgmt client) |
|
||||
| `builtin.go` | 86 | Registry + `FactoryContext` (ctx, data dir, meter, logger, mgmt client) |
|
||||
| `all_test.go` | 41 | Locks the 8-ID registry surface |
|
||||
| `agentnetwork_chain_integration_test.go` | 319 | Live sqlite + real gRPC bufconn; gate→recorder wire path |
|
||||
| `llm_request_parser/*` | 162 / 66 / 356 | Provider detection, body parse, prompt extraction with capture-pointer gating |
|
||||
@@ -53,7 +53,7 @@ locks the ID set; adding or removing one is a conscious extension.
|
||||
| `llm_identity_inject/*` | 440 / 108 / 666 | HeaderPair (LiteLLM) + JSONMetadata (Portkey) + ExtraHeaders |
|
||||
| `llm_guardrail/*` | 176 / 82 / 75 / 219 / 217 | Model allowlist + optional prompt capture with PII redaction |
|
||||
| `llm_response_parser/*` | 258 / 222 / 43 / 433 / 169 / 111 | Buffered + SSE accumulation; AWS event-stream accumulator (`streaming_bedrock.go`) for Bedrock; capture-pointer gates completion emit |
|
||||
| `cost_meter/*` | 236 / 98 / 586 | Token → USD via `proxy/internal/llm/pricing`; both pricing tiers arrive in the middleware config |
|
||||
| `cost_meter/*` | 181 / 84 / 439 | Token → USD via `proxy/internal/llm/pricing` |
|
||||
| `llm_limit_record/*` | 144 / 35 / 191 | Post-flight `RecordLLMUsage` (5s, debug-on-error) |
|
||||
|
||||
## Per-middleware
|
||||
@@ -168,46 +168,12 @@ token schema.
|
||||
|
||||
### cost_meter
|
||||
|
||||
Reads `llm.provider` + `llm.model` + token buckets, looks up the per-1k rates,
|
||||
and emits the full `cost.usd_*` breakdown (four per-bucket values plus the
|
||||
`_total` and `_cache` aggregates) or a closed-set `cost.skipped` reason
|
||||
(`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`,
|
||||
`unknown_model`).
|
||||
|
||||
**Management owns pricing.** The proxy carries no embedded price list: the whole
|
||||
table arrives in this middleware's `ConfigJSON` as
|
||||
`{pricing: {defaults, providers}}`, synthesized by management from the catalog
|
||||
plus the operator's stored per-provider prices
|
||||
([factory.go:13–34](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)).
|
||||
Both tiers are validated by `pricing.NewTable` / `pricing.NewEntries` at
|
||||
construction, so a non-finite or negative rate fails the chain build. A price
|
||||
change is an ordinary mapping push — the chain rebuild yields a fresh instance
|
||||
over a fresh immutable table, so there is no data dir, no pricing file, no
|
||||
reload goroutine, and nothing to invalidate.
|
||||
|
||||
**Two-tier lookup**
|
||||
([middleware.go:165–183](../../../proxy/internal/middleware/builtin/cost_meter/middleware.go)):
|
||||
|
||||
1. **Per-provider-record** — the operator's stored price for the route that
|
||||
actually served the request, keyed by the `llm.resolved_provider_id` that
|
||||
`llm_router` stamped on the allow path, then by normalized model id. Entries
|
||||
arrive fully materialized (management folds default cache rates in at synth
|
||||
time), so there is no merging here. Absent metadata — no router in the chain
|
||||
— skips this tier.
|
||||
2. **Surface defaults** — the catalog-derived table keyed by `llm.provider`
|
||||
(`openai`/`anthropic`/`bedrock`). This is also what prices gateway-style
|
||||
providers, which enumerate no models and therefore get no per-record entry.
|
||||
|
||||
**Backward compatibility:** a config with no `pricing` block means management
|
||||
predates config-delivered pricing. The factory logs one warning at build time
|
||||
and the instance records `cost.skipped=unknown_model` ($0) for every request
|
||||
rather than falling back to a stale built-in price list
|
||||
([factory.go:55–60](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)).
|
||||
|
||||
**Key invariant:** the provider-shape switch lives in `pricing.EntryCosts`
|
||||
(sibling doc) and is selected by the **surface**, not by which tier the entry
|
||||
came from — `cost_meter` stays provider-agnostic, and a per-record override on
|
||||
an Anthropic route still bills its cache buckets additively.
|
||||
Reads `llm.provider` + `llm.model` + token buckets, looks up per-1k rate via
|
||||
`pricing.Loader`, emits `cost.usd_total` or a closed-set `cost.skipped`
|
||||
reason (`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`,
|
||||
`unknown_model`). Loader's hot-reload goroutine is bound to proxy-lifetime
|
||||
context via `startReloader`. **Key invariant:** provider-shape switch lives
|
||||
in `pricing.Table.Cost` (sibling doc) — `cost_meter` stays provider-agnostic.
|
||||
|
||||
### llm_limit_record
|
||||
|
||||
@@ -280,14 +246,12 @@ no mocks. Tests: `TestChain_AllowPath_StampsAttributionAndRecordsCounter`
|
||||
| `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` |
|
||||
| `llm_guardrail` | `{provider_allowlists: {providerID: []string}, prompt_capture: {enabled, redact_pii}}` — allowlist keyed by resolved provider id; a provider absent from the map is unrestricted (fail-closed backstop; authoritative per-policy/group check is management's `CheckLLMPolicyLimits`) |
|
||||
| `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` |
|
||||
| `cost_meter` | `{pricing: {defaults: {surface: {model: rates}}, providers: {providerRecordID: {model: rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}`. A missing `pricing` key means "management predates config-delivered pricing": every request records `cost.skipped=unknown_model` |
|
||||
| `cost_meter` | `{pricing_path?}` (basename inside data-dir; defaults `pricing.yaml`) |
|
||||
| `llm_limit_record` | `{}` — same pattern as `llm_limit_check` |
|
||||
|
||||
All factories accept empty / null / `{}` / whitespace as zero-value config;
|
||||
only structurally invalid JSON is rejected so misconfig surfaces at chain
|
||||
build time. `cost_meter` adds a semantic check on top of that: a `pricing`
|
||||
block carrying a negative or non-finite rate fails the build too, rather than
|
||||
mispricing live traffic.
|
||||
build time.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -356,11 +320,10 @@ non-object `metadata` field
|
||||
— header path still attributes, but body-level tag-budget enforcement
|
||||
doesn't run for that request.
|
||||
|
||||
**Concurrency.** `cost_meter`'s two pricing tables are built once from the
|
||||
middleware config and never mutated, so the lookup path needs no lock or atomic
|
||||
swap — a price change replaces the whole instance. Every middleware is
|
||||
otherwise a stateless value receiver. Integration test uses real bufconn gRPC —
|
||||
race detector is the meaningful bar.
|
||||
**Concurrency.** `cost_meter` shares a `pricing.Loader` via
|
||||
`atomic.Pointer[Table]`; readers always see a consistent table. Every
|
||||
middleware is a stateless value receiver. Integration test uses real bufconn
|
||||
gRPC — race detector is the meaningful bar.
|
||||
|
||||
**Perf.** Hot path is `lookupKV` linear scan over <10 KVs; `cost_meter.Cost`
|
||||
is O(1); SSE accumulation is single-pass. No map allocation per call.
|
||||
@@ -386,13 +349,13 @@ counter accuracy.
|
||||
| `llm_guardrail/redact_test.go` | 15 | Email, SSN, phone (E.164 + NA), bearer, IPv4; fixture-driven |
|
||||
| `llm_response_parser/middleware_test.go` | 18 | Buffered OAI+Anthro, capture-pointer, redact, truncation |
|
||||
| `llm_response_parser/streaming_test.go` | 7 | OAI usage frame, Anthro message_delta, truncated body best-effort |
|
||||
| `cost_meter/middleware_test.go` | 22 | Each skip reason, provider-shape formulas, config-delivered defaults, per-record-beats-defaults + miss-falls-back, per-record uses surface formula, nil-pricing skips everything, invalid-rate rejection |
|
||||
| `cost_meter/middleware_test.go` | 17 | Each skip reason, provider-shape, pricing loader integration |
|
||||
| `llm_limit_record/middleware_test.go` | 7 | Skip-on-no-signal, skip-on-missing-attribution, RPC failure swallowed |
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Sibling: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — SDK adapters
|
||||
+ SSE framer + pricing table and cost formula.
|
||||
+ SSE framer + pricing loader.
|
||||
- Path-routed providers (Vertex AI + Bedrock), `keyfile::` credential, GCP
|
||||
token minting, `/bedrock` prefix:
|
||||
[50-path-routed-providers.md](./50-path-routed-providers.md).
|
||||
|
||||
@@ -9,7 +9,7 @@ pricing table's per-provider cost formula is the highest-leverage place a
|
||||
small bug would silently mis-bill operators.
|
||||
|
||||
Sibling module: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md)
|
||||
— the 8 middlewares that consume this package's parsers + pricing table.
|
||||
— the 8 middlewares that consume this package's parsers + pricing loader.
|
||||
|
||||
---
|
||||
|
||||
@@ -24,9 +24,8 @@ proxy-framework dependencies:
|
||||
- `openai.go` / `anthropic.go` / `bedrock.go` — per-provider `Parser` impls.
|
||||
- `sse.go` — SSE scanner (`Scanner`, `Event`, `NewScanner`).
|
||||
- `errors.go` — sentinels callers branch on with `errors.Is`.
|
||||
- `pricing/` — immutable pricing table + the per-surface cost formula. The
|
||||
rates themselves come from management inside `cost_meter`'s middleware
|
||||
config; this package holds no price list and reads no files.
|
||||
- `pricing/` — embedded-default + hot-reload override table with
|
||||
symlink-safe Unix loader (build-tagged stub elsewhere).
|
||||
- `fixtures/` — captured request/response/stream bodies the tests replay.
|
||||
|
||||
The package carries zero proxy-framework dependencies so the same parsers can
|
||||
@@ -48,9 +47,12 @@ be reused later by a WASM adapter
|
||||
| `sse_test.go` | 175 | 12 tests; fixture replay + multiline + size limits |
|
||||
| `parser_test.go` | 53 | `Parsers()`, `DetectParser`, provider enum values |
|
||||
| `errors.go` | 31 | 6 sentinels: `Err{Unknown,Unsupported}Provider/Model`, `Err{NotLLM,Malformed}Response`, `ErrStreamingUnsupported`, `ErrMalformedRequest` |
|
||||
| `pricing/pricing.go` | 234 | `Table`, `Entry`, `EntryJSON`, `Costs`; `NewTable`/`NewEntries` validation + `EntryCosts` formula. No I/O, no reload, no embedded rates |
|
||||
| `pricing/pricing_test.go` | 177 | 10 tests — provider-shape formulas, cached clamp, rate fallback, nil-safety, rate validation |
|
||||
| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream |
|
||||
| `pricing/pricing.go` | 421 | `Loader`, `Table`, `Entry`; embedded defaults + atomic swap + mtime reload |
|
||||
| `pricing/pricing_unix.go` | 69 | `O_NOFOLLOW` + fstat-from-FD + 1 MiB cap |
|
||||
| `pricing/pricing_other.go` | 21 | Stub returning "not supported on this platform" |
|
||||
| `pricing/pricing_test.go` | 432 | 21 tests — symlink rejection, reload race, path traversal, oversize |
|
||||
| `pricing/defaults_pricing.yaml` | 85 | go:embed source of truth |
|
||||
| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream + pricing starter |
|
||||
|
||||
## Request body → parser dispatch
|
||||
|
||||
@@ -186,11 +188,9 @@ response leg, covering both Bedrock body shapes:
|
||||
`totalTokens`). `firstNonZero` folds the two naming conventions into one
|
||||
`Usage`; when Converse omits `totalTokens` the parser sums the buckets.
|
||||
|
||||
`ProviderName()` returns `"bedrock"` — its own pricing surface in the table
|
||||
management ships, keyed by the **normalised** model id (region prefix + version
|
||||
suffix stripped by the request parser; management normalises its keys the same
|
||||
way at synth time so the two compare equal). `ParseResponse` returns
|
||||
`ErrStreamingUnsupported` for an
|
||||
`ProviderName()` returns `"bedrock"` — its own `defaults_pricing.yaml` block,
|
||||
keyed by the **normalised** model id (region prefix + version suffix stripped by
|
||||
the request parser). `ParseResponse` returns `ErrStreamingUnsupported` for an
|
||||
AWS binary event-stream content-type (`application/vnd.amazon.eventstream`,
|
||||
`isAWSEventStream`) so the caller routes to the streaming accumulator instead.
|
||||
|
||||
@@ -205,34 +205,11 @@ response body. Streaming accumulators live in the middleware package
|
||||
([llm_response_parser/streaming.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go))
|
||||
but use `llm.NewScanner` so the framing contract stays here.
|
||||
|
||||
### Pricing table
|
||||
### Pricing catalog
|
||||
|
||||
**Management is the sole pricing authority.** The proxy carries no embedded
|
||||
price list and reads no pricing file: the whole table arrives inside
|
||||
`cost_meter`'s `ConfigJSON` on the ordinary mapping push, and a price change
|
||||
is just another push — the chain rebuild constructs a fresh `Table`, so there
|
||||
is nothing to reload
|
||||
([pricing.go:1–7](../../../proxy/internal/llm/pricing/pricing.go)). The
|
||||
management side of the contract (catalog defaults, the operator's stored
|
||||
per-provider prices, and `AgentNetwork.PricingDefaultsFile`) is covered in the
|
||||
management-side module guide; `cost_meter`'s wire shape is in
|
||||
[31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md).
|
||||
|
||||
`EntryJSON`
|
||||
([pricing.go:36–45](../../../proxy/internal/llm/pricing/pricing.go)) is the
|
||||
management→proxy contract — five USD-per-1k rates under `input_per_1k`,
|
||||
`output_per_1k`, `cached_input_per_1k`, `cache_read_per_1k`,
|
||||
`cache_creation_per_1k`. Management's `pricing.Entry` marshals the identical
|
||||
names, and `EntryJSON`/`Entry` are field-identical so `NewEntries` converts by
|
||||
direct struct conversion rather than field-by-field copying (a new rate can't
|
||||
be silently dropped in transit).
|
||||
|
||||
`EntryCosts`
|
||||
([pricing.go:183–234](../../../proxy/internal/llm/pricing/pricing.go))
|
||||
is the cost formula — most security-relevant math in this module. The
|
||||
**surface** (the `llm.provider` value the request parser stamped) selects the
|
||||
formula, never the tier the entry came from: a per-provider-record override on
|
||||
an Anthropic route still bills its cache buckets additively.
|
||||
`Table.Cost`
|
||||
([pricing.go:129–174](../../../proxy/internal/llm/pricing/pricing.go))
|
||||
is the cost formula — most security-relevant math in this module:
|
||||
|
||||
| Provider | Formula |
|
||||
|---|---|
|
||||
@@ -241,7 +218,7 @@ an Anthropic route still bills its cache buckets additively.
|
||||
| default | `inTokens × InputPer1K + outTokens × OutputPer1K` |
|
||||
|
||||
`bedrock` shares the Anthropic additive-cache formula
|
||||
([pricing.go:214–229](../../../proxy/internal/llm/pricing/pricing.go)):
|
||||
([pricing.go:172-174](../../../proxy/internal/llm/pricing/pricing.go)):
|
||||
Anthropic-on-Bedrock reports the same additive cache buckets, while non-Anthropic
|
||||
Bedrock models (Nova, Llama) simply report zero in those buckets so cost reduces
|
||||
to `input + output`.
|
||||
@@ -249,12 +226,15 @@ to `input + output`.
|
||||
Each per-bucket rate falls back to `InputPer1K` when zero — operators opt in
|
||||
to discounts by setting the field.
|
||||
|
||||
`Costs`
|
||||
([pricing.go:143–163](../../../proxy/internal/llm/pricing/pricing.go)) is the
|
||||
per-request split. The four per-bucket fields are the base; `TotalUSD` and
|
||||
`CacheUSD` are **derived** in `newCosts` so the aggregates can never drift from
|
||||
the breakdown. `InputUSD` is always the non-cached input bucket on both
|
||||
provider shapes, so input and cached-input never double-count.
|
||||
`Loader`
|
||||
([pricing.go:212–268](../../../proxy/internal/llm/pricing/pricing.go))
|
||||
overlays an optional `pricing.yaml` from data-dir on top of the go:embed
|
||||
defaults. Atomic pointer swap means readers never observe a partial update.
|
||||
The mtime-poll reloader (30s default cadence) keeps the previous table on
|
||||
parse failure so cost annotation never goes blank during a botched edit.
|
||||
|
||||
`defaults_pricing.yaml` is the source of truth for built-in pricing.
|
||||
Operator overrides only carry the entries they want to change.
|
||||
|
||||
## Public contracts
|
||||
|
||||
@@ -284,38 +264,29 @@ Order matters: `DetectFromURL` ties resolve by registration order.
|
||||
`ProviderBedrock = 3`. Numeric values are persisted in nothing today but treat
|
||||
them as wire-stable — new providers must take fresh numbers.
|
||||
|
||||
**`Pricing` construction + lookup**
|
||||
([pricing.go:60–130](../../../proxy/internal/llm/pricing/pricing.go)):
|
||||
**`Pricing` lookup**
|
||||
([pricing.go:129](../../../proxy/internal/llm/pricing/pricing.go)):
|
||||
|
||||
```go
|
||||
func NewEntries(raw map[string]map[string]EntryJSON) (map[string]map[string]Entry, error)
|
||||
func NewTable(raw map[string]map[string]EntryJSON) (*Table, error)
|
||||
|
||||
func (t *Table) Lookup(provider, model string) (Entry, bool)
|
||||
func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool)
|
||||
func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool)
|
||||
func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, cacheCreation int64) Costs
|
||||
```
|
||||
|
||||
`NewTable` is the surface-keyed defaults table; `NewEntries` returns the raw
|
||||
two-level map `cost_meter` uses for the per-provider-record tier (it looks up an
|
||||
`Entry` directly and calls `EntryCosts`, so it needs no `Table` wrapper). Both
|
||||
reject any non-finite or negative rate, so a corrupt config fails the chain
|
||||
build rather than mispricing silently. Nil input yields an empty,
|
||||
never-matching table.
|
||||
|
||||
Nil-safe: `t.Cost`/`t.Lookup` on a nil receiver returns `ok=false`
|
||||
([pricing.go:96–99](../../../proxy/internal/llm/pricing/pricing.go)).
|
||||
`ok=false` means the surface or model is absent from the table management sent;
|
||||
the caller emits `cost.skipped=unknown_model`.
|
||||
Nil-safe: `t.Cost` on a nil receiver returns `(0, false)`
|
||||
([pricing.go:130–132](../../../proxy/internal/llm/pricing/pricing.go)).
|
||||
`ok=false` means provider or model is absent from the loaded table; the caller
|
||||
emits `cost.skipped=unknown_model`.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. **The pricing package is pure and platform-independent.** No file I/O, no
|
||||
`//go:embed`, no goroutines, no build tags — the rates arrive as config, so
|
||||
there is nothing platform-specific left to port. Anything reintroducing a
|
||||
read-from-disk path here re-splits pricing authority between management and
|
||||
the proxy, which is exactly what this design removed.
|
||||
1. **Cross-platform pricing build.** `pricing_unix.go` carries the only
|
||||
functional `loadPricing` (uses `syscall.O_NOFOLLOW` and `f.Stat()` on an
|
||||
open descriptor — both Unix-only). `pricing_other.go` is a build-tag
|
||||
fallback that returns `"not supported on this platform"`
|
||||
([pricing_other.go:14–16](../../../proxy/internal/llm/pricing/pricing_other.go)).
|
||||
The proxy is Linux-only in production today; a Windows port needs an
|
||||
equivalent path-as-handle implementation. Reviewers building on Windows
|
||||
should expect this surface to return an error at startup if an override
|
||||
file is configured.
|
||||
|
||||
2. **SSE scanner handles partial chunks.** A buffered prefix that doesn't end
|
||||
in `\n\n` still yields its accumulated event before `io.EOF`
|
||||
@@ -327,45 +298,38 @@ the caller emits `cost.skipped=unknown_model`.
|
||||
usage rather than aborting
|
||||
([streaming.go:68–73, 144–150](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)).
|
||||
|
||||
3. **Management is the only source of rates.** `Table` has no constructor that
|
||||
invents prices: the only way in is `NewTable`/`NewEntries` over the wire map
|
||||
management sent. A missing or empty `pricing` block therefore means *no
|
||||
prices at all* (`cost_meter` records `cost.skipped=unknown_model`, $0) —
|
||||
never a stale built-in fallback that would silently bill list price.
|
||||
3. **`defaults_pricing.yaml` is the source of truth.** Compiled into the
|
||||
binary via `//go:embed`
|
||||
([pricing.go:29–30](../../../proxy/internal/llm/pricing/pricing.go)).
|
||||
`DefaultTable()` parses once and panics on parse failure
|
||||
([pricing.go:42–49](../../../proxy/internal/llm/pricing/pricing.go))
|
||||
— by design: a broken embedded YAML must not ship to production.
|
||||
|
||||
4. **Tables are immutable once built.** `Table.entries` is written only in
|
||||
`NewEntries` and never mutated afterwards, and `cost_meter`'s `perRecord`
|
||||
map is likewise build-time-only
|
||||
([pricing.go:47–52](../../../proxy/internal/llm/pricing/pricing.go)). This
|
||||
is what makes the no-reload design safe: a price change arrives as a mapping
|
||||
push that builds a new middleware instance over a new table, so concurrent
|
||||
readers can't observe a half-updated price list and no atomic swap or lock
|
||||
is needed on the hot path.
|
||||
4. **Loader path validation.** `resolveMiddlewareDataPath`
|
||||
([pricing.go:370–394](../../../proxy/internal/llm/pricing/pricing.go))
|
||||
rejects absolute paths, traversal segments, and basenames that fail
|
||||
`basenameRegex = ^[a-zA-Z0-9._-]+$`. The resolved path must remain
|
||||
inside `baseDir` even after `filepath.Clean`. Tests:
|
||||
`TestNewLoader_PathValidation`, `TestNewLoader_PathValidation_Extended`,
|
||||
`TestNewLoader_SymlinkOutsideBaseDirRejected`, `TestNewLoader_SymlinkRejected`.
|
||||
|
||||
5. **Rate validation happens at chain-build time, not per request.**
|
||||
`NewEntries` rejects negative, NaN, and ±Inf rates field by field
|
||||
([pricing.go:60–83](../../../proxy/internal/llm/pricing/pricing.go)), naming
|
||||
the offending surface/model/field in the error. Management enforces the same
|
||||
constraints at its API boundary and in its YAML parser, so this is
|
||||
defense-in-depth — but it means a corrupt push fails loudly at build instead
|
||||
of producing negative costs on live traffic. Test:
|
||||
`TestNewTable_ValidatesRates`.
|
||||
5. **Unix loader symlink safety.** `O_NOFOLLOW` on open, `f.Stat()` on the
|
||||
open descriptor (never re-stat by path), `info.Mode().IsRegular()` check,
|
||||
`io.LimitReader(f, maxPricingBytes+1)` with a final size assertion
|
||||
([pricing_unix.go:25–57](../../../proxy/internal/llm/pricing/pricing_unix.go)).
|
||||
A mid-read symlink swap is detected because the fstat is on the original
|
||||
fd. Test: `TestNewLoader_RejectsOversizedFile_FixesM4`.
|
||||
|
||||
6. **New rates must be added to `Entry`, `EntryJSON`, *and* management's
|
||||
`pricing.Entry` together.** `NewEntries` converts by direct struct
|
||||
conversion `Entry(e)`
|
||||
([pricing.go:76–78](../../../proxy/internal/llm/pricing/pricing.go)), which
|
||||
only compiles while the two structs stay field-identical — so the proxy half
|
||||
is compiler-enforced. The management half is not: a rate added there but not
|
||||
here unmarshals into nothing and prices that bucket at `InputPer1K`.
|
||||
6. **`yaml.NewDecoder(...).KnownFields(true)`**
|
||||
([pricing.go:397–398](../../../proxy/internal/llm/pricing/pricing.go))
|
||||
rejects YAML files that carry fields not in the schema. A typo in an
|
||||
operator override file fails loud instead of silently zeroing rates.
|
||||
|
||||
## Things to scrutinise
|
||||
|
||||
**Correctness.** Verify the OpenAI cached-prompt clamp at
|
||||
[pricing.go:203–206](../../../proxy/internal/llm/pricing/pricing.go)
|
||||
short-circuits before subtraction. Negative token counts are clamped to zero up
|
||||
front ([pricing.go:186–197](../../../proxy/internal/llm/pricing/pricing.go)) so
|
||||
no formula can yield a negative cost. `Anthropic.TotalTokens` sums all four
|
||||
**Correctness.** Verify OpenAI cached-prompt clamp at
|
||||
[pricing.go:147–149](../../../proxy/internal/llm/pricing/pricing.go)
|
||||
short-circuits before subtraction. `Anthropic.TotalTokens` sums all four
|
||||
buckets (in + out + cache_read + cache_creation) — downstream dashboards
|
||||
need to know this differs from `input + output`.
|
||||
`OpenAIParser.ExtractPrompt` falls through `messages → input → prompt`; a
|
||||
@@ -374,27 +338,22 @@ noting).
|
||||
|
||||
**Security.** `Scanner.maxLine = 1 MiB`; a 2 MiB single-line `data:` event
|
||||
errors from `Scanner.Next` and both accumulators stop with partial usage.
|
||||
Pricing is no longer file-backed, so the loader's path-traversal / symlink /
|
||||
oversize surface is gone entirely — the config channel (an authenticated
|
||||
mapping push from management) is now the only way rates enter the proxy, and
|
||||
`NewEntries` is the validation boundary on it. A new rate added to management's
|
||||
`pricing.Entry` but not to `EntryJSON` here is the remaining silent-mispricing
|
||||
path (see invariant 6).
|
||||
Pricing file 1 MiB cap is orders of magnitude larger than realistic. Confirm
|
||||
new schema additions are mirrored in both `pricingFile` and `Entry`;
|
||||
`KnownFields(true)` will reject silently-typo'd operator overrides
|
||||
otherwise.
|
||||
|
||||
**Concurrency.** Nothing in this package is shared mutable state: tables are
|
||||
built once and never written again, so `cost_meter`'s hot path is lock-free by
|
||||
construction rather than by atomic swap. Per-call `Scanner` instances mean no
|
||||
shared state across concurrent response-parser calls.
|
||||
**Concurrency.** `Loader.table` is `atomic.Pointer[Table]`; readers never
|
||||
block or see a torn table. `Loader.Reload` is one goroutine, cancelled via
|
||||
context (`TestLoader_ReloadBackgroundLoopCancellation`). `DefaultTable()`
|
||||
uses `sync.Once`. Per-call `Scanner` instances mean no shared state across
|
||||
concurrent response-parser calls.
|
||||
|
||||
**Perf.** `Table.Cost` is two map lookups + multiplications, O(1); the
|
||||
per-provider-record tier adds at most one more lookup. `Scanner.Next` is one
|
||||
`ReadString('\n')` per line. No background goroutines and no per-request
|
||||
allocation of pricing state.
|
||||
**Perf.** `Table.Cost` is two map lookups + multiplications, O(1).
|
||||
`Scanner.Next` is one `ReadString('\n')` per line. Pricing reload poll 30s.
|
||||
|
||||
**Observability.** A config carrying no `pricing` block logs one warning at
|
||||
chain-build time (`cost_meter` factory) and then records
|
||||
`cost.skipped=unknown_model` per request, so an old-management deployment is
|
||||
visible in both logs and the access log rather than quietly reporting $0.
|
||||
**Observability.** Reload failures count via `metric.Int64Counter` keyed
|
||||
`plugin`; warning log rate-limited at 5 min so a broken file doesn't flood.
|
||||
Parser errors return sentinels — middleware uses `errors.Is` to map to the
|
||||
right `cost.skipped` reason.
|
||||
|
||||
@@ -406,7 +365,7 @@ right `cost.skipped` reason.
|
||||
| `openai_test.go` | 11 | Chat Completions + Responses API + legacy `prompt`; cached-tokens subset for both naming conventions; fixture replays |
|
||||
| `anthropic_test.go` | 7 | Messages + legacy `/v1/complete`; streaming REJECTED on `ParseResponse` (must use scanner); fixture replays |
|
||||
| `sse_test.go` | 12 | Fixture replay both providers; multiline `data:`; CRLF; comment skip; trailing-event-without-blank-line; oversize rejection |
|
||||
| `pricing/pricing_test.go` | 10 | Provider-shape switch (surface selects the formula); cached-rate + cache-read/creation fallback to `InputPer1K`; cached-clamp; negative-token clamp; nil-receiver safety; rate validation (negative / NaN / Inf rejected); nil + empty table |
|
||||
| `pricing/pricing_test.go` | 21 | Provider-shape switch; cached-rate fallback; cached-clamp; symlink rejection (target outside basedir + symlink to file); path validation matrix; oversize rejection; reload-keeps-previous-on-parse-error; mtime change detection; goroutine cancellation |
|
||||
|
||||
**Fixtures** ([proxy/internal/llm/fixtures/](../../../proxy/internal/llm/fixtures/)):
|
||||
`openai_chat_completion.json` (chat.completions with usage),
|
||||
@@ -414,15 +373,14 @@ right `cost.skipped` reason.
|
||||
`openai_stream.txt` (3 deltas + usage + `[DONE]`),
|
||||
`anthropic_messages.json` (Messages API non-streaming),
|
||||
`anthropic_stream.txt` (full 7-event sequence: message_start →
|
||||
content_block_{start,delta×2,stop} → message_delta (usage) → message_stop).
|
||||
No pricing fixture: the table is config-delivered, so pricing tests construct
|
||||
it in-process from a wire-shape map.
|
||||
content_block_{start,delta×2,stop} → message_delta (usage) → message_stop),
|
||||
`pricing.yaml` (realistic-pricing starter for operator overrides).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Sibling: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md)
|
||||
— the chain that calls `llm.Parsers()`, `llm.ParserByName`,
|
||||
`llm.NewScanner`, `pricing.NewTable` / `pricing.NewEntries`.
|
||||
`llm.NewScanner`, `pricing.NewLoader`.
|
||||
- Path-routed providers (Vertex AI + Bedrock), credential syntax, and the
|
||||
Bedrock AWS event-stream accumulator:
|
||||
[50-path-routed-providers.md](./50-path-routed-providers.md).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# proxy/runtime — translate + serve + log
|
||||
|
||||
> **Risk level:** High — every config push from management is translated here, and the chain runs on every HTTP request to a synth target.
|
||||
> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path. Middleware config is entirely wire-delivered — no proxy-side data dir is involved, including for LLM pricing, which management ships inside `cost_meter`'s config.
|
||||
> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareDataDir`, `MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path.
|
||||
|
||||
## Module boundary
|
||||
|
||||
@@ -114,7 +114,8 @@ At **request time** the access-log middleware stamps `CapturedData`; the auth ch
|
||||
|
||||
## Public contracts touched
|
||||
|
||||
- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:249-253). There is no `MiddlewareDataDir`: no built-in middleware reads config from disk, so `builtin.FactoryContext` carries only the proxy-lifetime context, meter, logger, and management client.
|
||||
- `proxy.Server.MiddlewareDataDir` (string) — base dir for file-backed middleware config (server.go:238-241).
|
||||
- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:248-250).
|
||||
- `proxy/internal/proxy.WithMiddlewareManager(*middleware.Manager) Option` — new option on `NewReverseProxy`; nil keeps the fast path (reverseproxy.go:48-56).
|
||||
- `proxy/internal/proxy.PathTarget` adds `Middlewares`, `CaptureConfig`, `AgentNetwork`, `DisableAccessLog` (servicemapping.go:27-51), all zero-default.
|
||||
- `proxy/internal/proxy.CapturedData` adds `agentNetwork`, `suppressAccessLog`, `userGroupNames` behind `sync.RWMutex`; slices deep-copied (context.go:47-66, 183-258).
|
||||
|
||||
@@ -87,9 +87,9 @@ strips the `@version` suffix from the model, and maps the publisher to a parser
|
||||
surface via `vertexPublisherVendor`:
|
||||
|
||||
- `anthropic` → `llm.provider="anthropic"` → metered through the Anthropic
|
||||
parser, priced under the **`anthropic`** surface of the pricing table
|
||||
management ships (the parser emits the standard Anthropic provider label, so
|
||||
Vertex Claude reuses first-party Anthropic prices).
|
||||
parser, priced under the **`anthropic`** block in `defaults_pricing.yaml`
|
||||
(the parser emits the standard Anthropic provider label, so Vertex Claude
|
||||
reuses first-party Anthropic prices).
|
||||
- `openai` → `llm.provider="openai"` (reserved; not in the catalog lineup
|
||||
today).
|
||||
- anything else (notably `google` / Gemini) → empty vendor → **no parser**.
|
||||
@@ -104,9 +104,8 @@ is omitted from the catalog.
|
||||
|
||||
> Caveat: cross-region inference profiles in `eu` / `apac` carry a ~10% price
|
||||
> premium that the base per-token rates do **not** model — cost annotations for
|
||||
> those regions read low. Operators who need exact regional billing set the
|
||||
> affected models' prices on the provider record, or replace the default entries
|
||||
> via management's `AgentNetwork.PricingDefaultsFile`.
|
||||
> those regions read low. Operators who need exact regional billing override
|
||||
> the affected entries in `pricing.yaml`.
|
||||
|
||||
## AWS Bedrock (`bedrock_api`)
|
||||
|
||||
@@ -212,19 +211,15 @@ so a model-listing call can't be rewritten onto an upstream that would 404 it.
|
||||
## Catalog ↔ pricing cross-check
|
||||
|
||||
Catalog prices and context windows are cross-checked against LiteLLM's
|
||||
`model_prices_and_context_window.json`. The **catalog is the source of default
|
||||
prices**: management's `pricing.DefaultTable` folds every catalog provider's
|
||||
models into the surfaces that provider declares (`PricingSurfaces`), so coverage
|
||||
is structural rather than maintained in a parallel file
|
||||
([pricing/defaults.go](../../../management/internals/modules/agentnetwork/pricing/defaults.go)).
|
||||
`TestDefaultTable_CoversEveryCatalogModel` fails if a catalog model ends up
|
||||
unpriced, and `TestDefaultTable_NoConflictingContributions` fails if two
|
||||
providers contribute the same (surface, model) at different rates. Bedrock
|
||||
entries are keyed by the **normalised** id the request parser emits (region
|
||||
prefix + version suffix stripped) — management applies the same normalisation to
|
||||
per-provider prices at synth time, so the two keys compare equal. Vertex Claude
|
||||
carries no Bedrock-style prefix, so it prices straight off the `anthropic`
|
||||
surface.
|
||||
`model_prices_and_context_window.json`. The proxy's embedded
|
||||
`defaults_pricing.yaml` covers **every metered first-party model** the catalog
|
||||
enumerates — guarded by
|
||||
`TestDefaultTable_FirstPartyModelCoverage`
|
||||
([pricing/defaults_coverage_test.go](../../../proxy/internal/llm/pricing/defaults_coverage_test.go)),
|
||||
which fails if a catalog model has no embedded price. Bedrock entries are keyed
|
||||
by the **normalised** id the request parser emits (region prefix + version
|
||||
suffix stripped). Vertex Claude carries no Bedrock-style prefix, so it prices
|
||||
straight off the `anthropic` block.
|
||||
|
||||
## Things to scrutinise
|
||||
|
||||
@@ -237,17 +232,16 @@ operator-misconfigured Vertex provider and unmetered Gemini traffic; verify
|
||||
publishers).
|
||||
|
||||
**Correctness.** `normalizeBedrockModel` is the join between the wire id and the
|
||||
pricing key — a model that normalises to something absent from the shipped
|
||||
pricing table meters at `cost.skipped=unknown_model` rather than failing the
|
||||
request. The
|
||||
pricing key — a model that normalises to something not in `defaults_pricing.yaml`
|
||||
meters at `cost.skipped=unknown_model` rather than failing the request. The
|
||||
`/bedrock` prefix strip must run on both the parser side (so the model is
|
||||
extracted) and the router side (so the upstream path is native); a regression in
|
||||
either silently breaks the other.
|
||||
|
||||
**Metering caveats.** eu/apac cross-region Bedrock + Vertex profiles carry a
|
||||
~10% premium not modelled by base pricing — flagged in the catalog comment.
|
||||
Operators needing exact regional billing set per-provider prices on the model
|
||||
rows (or replace the default entries via `AgentNetwork.PricingDefaultsFile`).
|
||||
~10% premium not modelled by base pricing — flagged in both the catalog comment
|
||||
and `defaults_pricing.yaml`. Operators needing exact regional billing override
|
||||
the relevant entries.
|
||||
|
||||
## Cross-references
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@ import (
|
||||
// model the client asks for. The proxy prices off the REQUEST model, not the
|
||||
// upstream response model, so a made-up model id billed at operator rates lets
|
||||
// these tests assert exact costs without a real vendor key.
|
||||
// Sourced from the harness so the counts can't drift from the mock's config.
|
||||
const (
|
||||
vllmPromptTokens = 11
|
||||
vllmCompletionTokens = 2
|
||||
vllmPromptTokens = harness.VLLMChatInputTokens
|
||||
vllmCompletionTokens = harness.VLLMChatOutputTokens
|
||||
)
|
||||
|
||||
// pricedEnv is a connected single-provider agent-network deployment pointed at
|
||||
@@ -169,23 +170,43 @@ func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID
|
||||
return body
|
||||
}
|
||||
|
||||
// findAccessLogBySession polls the access-log page for the row carrying sessionID.
|
||||
func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog {
|
||||
t.Helper()
|
||||
var row api.AgentNetworkAccessLog
|
||||
require.Eventually(t, func() bool {
|
||||
logs, lerr := srv.ListAccessLogs(ctx)
|
||||
if lerr != nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range logs.Data {
|
||||
if r.SessionId != nil && *r.SessionId == sessionID {
|
||||
row = r
|
||||
return true
|
||||
// accessLogIngestWindow is how long a single request's access-log row is given
|
||||
// to appear before the caller gives up on it.
|
||||
const accessLogIngestWindow = 30 * time.Second
|
||||
|
||||
// lookupAccessLogBySession polls the access-log page for the row carrying
|
||||
// sessionID and reports whether it arrived within the window. It never fails
|
||||
// the test: callers that can recover — by firing a fresh request under a new
|
||||
// session — need to see the miss rather than die on it.
|
||||
func lookupAccessLogBySession(ctx context.Context, sessionID string, within time.Duration) (api.AgentNetworkAccessLog, bool) {
|
||||
deadline := time.Now().Add(within)
|
||||
for {
|
||||
if logs, lerr := srv.ListAccessLogs(ctx); lerr == nil {
|
||||
for _, r := range logs.Data {
|
||||
if r.SessionId != nil && *r.SessionId == sessionID {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID)
|
||||
if time.Now().After(deadline) {
|
||||
return api.AgentNetworkAccessLog{}, false
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return api.AgentNetworkAccessLog{}, false
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findAccessLogBySession polls the access-log page for the row carrying
|
||||
// sessionID, failing the test if it never lands. Use it for a request whose row
|
||||
// must exist; where a missing row is a recoverable race, use
|
||||
// lookupAccessLogBySession and retry.
|
||||
func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog {
|
||||
t.Helper()
|
||||
row, ok := lookupAccessLogBySession(ctx, sessionID, accessLogIngestWindow)
|
||||
require.True(t, ok, "session id %q must be recorded in an access-log row", sessionID)
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -319,6 +340,11 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
|
||||
outRateA = 0.020
|
||||
inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable
|
||||
outRateB = 0.080
|
||||
// Per-attempt ingest wait, shorter than the default so a request that
|
||||
// produces no row costs one retry rather than most of the budget, and an
|
||||
// overall deadline long enough to hold several attempts.
|
||||
repriceIngestWindow = 20 * time.Second
|
||||
repriceDeadline = 180 * time.Second
|
||||
)
|
||||
|
||||
env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{
|
||||
@@ -353,10 +379,15 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
|
||||
// reading its cost, so an un-ingested row is never mistaken for "still rate A".
|
||||
// The expected new input cost is unmistakably higher than rate A, so a
|
||||
// lingering old-rate row can't satisfy the check.
|
||||
//
|
||||
// Every way an iteration can come up short — the request failing, its row not
|
||||
// landing, or the row still carrying rate A — is a symptom of the same
|
||||
// in-flight rebuild, so each one retries under a fresh session rather than
|
||||
// ending the test. Only the outer deadline is fatal.
|
||||
wantInputB := float64(vllmPromptTokens) / 1000 * inRateB
|
||||
var repriced api.AgentNetworkAccessLog
|
||||
var lastSession string
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
deadline := time.Now().Add(repriceDeadline)
|
||||
for time.Now().Before(deadline) {
|
||||
lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano())
|
||||
code, _, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession)
|
||||
@@ -364,7 +395,15 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
row := findAccessLogBySession(t, ctx, lastSession)
|
||||
row, ok := lookupAccessLogBySession(ctx, lastSession, repriceIngestWindow)
|
||||
if !ok {
|
||||
// No row for this request. The provider update rebuilds the proxy's
|
||||
// middleware chain, and a request served mid-rebuild can complete
|
||||
// without a resolved provider — 200 to the caller, nothing to
|
||||
// attribute, so no row is ever written for it. Fire another one.
|
||||
t.Logf("no access-log row for session %q within %s; retrying under a fresh session", lastSession, repriceIngestWindow)
|
||||
continue
|
||||
}
|
||||
if inDelta(row.InputCostUsd, wantInputB, 1e-6) {
|
||||
repriced = row
|
||||
break
|
||||
@@ -630,3 +669,47 @@ func inDelta(a, b, tol float64) bool {
|
||||
}
|
||||
return d <= tol
|
||||
}
|
||||
|
||||
// TestCustomDatedModelKeepsItsOwnPrice covers the review fix that anchored the
|
||||
// release-date fallback to Claude ids. Pricing looks every model up through
|
||||
// that helper, so while it matched a bare trailing date any operator id ending
|
||||
// in eight digits inherited the rate of its undated sibling — a silent
|
||||
// mis-bill on models NetBird knows nothing about.
|
||||
func TestCustomDatedModelKeepsItsOwnPrice(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
const (
|
||||
baseModel = "internal-llm"
|
||||
datedModel = "internal-llm-20250101"
|
||||
baseIn = 0.010
|
||||
baseOut = 0.020
|
||||
// An order of magnitude apart, so a row billed at the wrong entry is
|
||||
// unmistakable rather than a rounding argument.
|
||||
datedIn = 0.100
|
||||
datedOut = 0.200
|
||||
)
|
||||
|
||||
env := provisionPricedProvider(t, ctx, "customdated", []api.AgentNetworkProviderModel{
|
||||
{Id: baseModel, InputPer1k: baseIn, OutputPer1k: baseOut},
|
||||
{Id: datedModel, InputPer1k: datedIn, OutputPer1k: datedOut},
|
||||
})
|
||||
|
||||
t.Run("the undated id bills at its own rate", func(t *testing.T) {
|
||||
session := fmt.Sprintf("e2e-session-customdated-base-%d", time.Now().UnixNano())
|
||||
chatOnce(t, ctx, env, baseModel, session)
|
||||
assertOpenAICostAtRates(t, findAccessLogBySession(t, ctx, session), baseIn, baseOut)
|
||||
})
|
||||
|
||||
t.Run("the dated id keeps its own rate", func(t *testing.T) {
|
||||
session := fmt.Sprintf("e2e-session-customdated-dated-%d", time.Now().UnixNano())
|
||||
chatOnce(t, ctx, env, datedModel, session)
|
||||
row := findAccessLogBySession(t, ctx, session)
|
||||
assertOpenAICostAtRates(t, row, datedIn, datedOut)
|
||||
|
||||
// Spelled out because it is the regression: inheriting the sibling's
|
||||
// rate would bill this request at a tenth of its price.
|
||||
assert.Greater(t, row.InputCostUsd, float64(vllmPromptTokens)/1000*baseIn*2,
|
||||
"a custom dated id must not inherit the undated entry's rate")
|
||||
})
|
||||
}
|
||||
|
||||
455
e2e/agentnetwork/gateway_protocol_test.go
Normal file
455
e2e/agentnetwork/gateway_protocol_test.go
Normal file
@@ -0,0 +1,455 @@
|
||||
//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())
|
||||
})
|
||||
}
|
||||
242
e2e/agentnetwork/gateway_review_test.go
Normal file
242
e2e/agentnetwork/gateway_review_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
//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,
|
||||
}
|
||||
}
|
||||
199
e2e/agentnetwork/streaming_test.go
Normal file
199
e2e/agentnetwork/streaming_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
//go:build e2e
|
||||
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
// streamedModel is priced high enough that a mis-metered request is obvious in
|
||||
// the recorded cost, and named so it cannot collide with another test's route.
|
||||
const streamedModel = "e2e-streamed-model"
|
||||
|
||||
const (
|
||||
streamInRate = 0.010
|
||||
streamOutRate = 0.020
|
||||
// The cache-read bucket is priced separately from input, so a run that
|
||||
// folded the two together fails the per-bucket assertions below.
|
||||
streamCacheReadRate = 0.001
|
||||
)
|
||||
|
||||
// TestStreamingResponseMetersInputTokens is the end-to-end guard for the
|
||||
// metering bug this endpoint's gateway-protocol work fixed.
|
||||
//
|
||||
// On a streamed answer the input-token count exists only in the opening
|
||||
// message_start event; every later frame reports output. A response read with
|
||||
// the wrong vendor's parser — the shape a gateway record produces when it names
|
||||
// one API surface and serves another — never looks at that event, so input
|
||||
// metered as zero and the bulk of the bill silently vanished. Nothing in the
|
||||
// suite sent stream: true before this test, so the whole branch went unrun.
|
||||
//
|
||||
// The provider points at the mock's streaming listener, which answers every
|
||||
// request as SSE with token counts that differ from the buffered surface. That
|
||||
// difference is the point: passing these assertions is only possible if the
|
||||
// stream accumulator ran.
|
||||
func TestStreamingResponseMetersInputTokens(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
env := provisionStreamingProvider(t, ctx, "anthropic_api")
|
||||
|
||||
sessionID := fmt.Sprintf("e2e-session-stream-%d", time.Now().UnixNano())
|
||||
code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID)
|
||||
require.Equal(t, 200, code, "streamed chat must succeed; body: %s", body)
|
||||
assert.Contains(t, body, "message_start",
|
||||
"the client must receive the event stream itself, not a buffered rewrite of it")
|
||||
|
||||
row := findAccessLogBySession(t, ctx, sessionID)
|
||||
|
||||
assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens),
|
||||
"input tokens live in message_start; zero here is the bug this test exists for")
|
||||
assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens),
|
||||
"output tokens ride message_delta and supersede the message_start seed")
|
||||
assert.Equal(t, harness.VLLMStreamCacheReadTokens, int(row.CachedInputTokens),
|
||||
"the Anthropic cache bucket rides message_start too, and only its own parser reads it")
|
||||
|
||||
// The Anthropic surface bills cache reads additively, so the input bucket
|
||||
// prices the full input count rather than a remainder.
|
||||
wantInput := float64(harness.VLLMStreamInputTokens) / 1000 * streamInRate
|
||||
wantOutput := float64(harness.VLLMStreamOutputTokens) / 1000 * streamOutRate
|
||||
assert.InDelta(t, wantInput, row.InputCostUsd, 1e-6, "input cost must price the streamed input tokens")
|
||||
assert.InDelta(t, wantOutput, row.OutputCostUsd, 1e-6, "output cost must price the streamed output tokens")
|
||||
assert.Greater(t, row.CostUsd, 0.0, "a streamed request must never record as free")
|
||||
}
|
||||
|
||||
// TestStreamingOnGatewayTypedProvider drives the same streamed Anthropic call
|
||||
// through a provider record whose catalog id names the OpenAI surface — the
|
||||
// exact misconfiguration that hid the bug, since gateway records commonly pin
|
||||
// one parser while the upstream serves another shape entirely.
|
||||
//
|
||||
// The router must choose the parser from the request path rather than the
|
||||
// record's provider id, or the Anthropic usage block goes unread and input
|
||||
// meters at zero all over again.
|
||||
func TestStreamingOnGatewayTypedProvider(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
env := provisionStreamingProvider(t, ctx, "openai_api")
|
||||
|
||||
sessionID := fmt.Sprintf("e2e-session-stream-gw-%d", time.Now().UnixNano())
|
||||
code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID)
|
||||
require.Equal(t, 200, code, "streamed chat through a gateway record must succeed; body: %s", body)
|
||||
|
||||
row := findAccessLogBySession(t, ctx, sessionID)
|
||||
|
||||
assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens),
|
||||
"a record typed openai_api must still read the Anthropic usage block it is actually serving")
|
||||
assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens),
|
||||
"output tokens must survive the surface mismatch too")
|
||||
assert.InDelta(t, float64(harness.VLLMStreamInputTokens)/1000*streamInRate, row.InputCostUsd, 1e-6,
|
||||
"the request must be priced on the surface it spoke, not the one the record names")
|
||||
}
|
||||
|
||||
// provisionStreamingProvider brings up the mock, one provider pointed at its
|
||||
// streaming listener under the given catalog id, a policy authorising it, and a
|
||||
// connected proxy + client.
|
||||
func provisionStreamingProvider(t *testing.T, ctx context.Context, catalogID string) pricedEnv {
|
||||
t.Helper()
|
||||
|
||||
vllm, err := harness.StartVLLM(ctx, srv)
|
||||
require.NoError(t, err, "start mock upstream")
|
||||
t.Cleanup(func() { _ = vllm.Terminate(context.Background()) })
|
||||
|
||||
name := "stream-" + catalogID
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-" + name})
|
||||
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-" + name + "-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-stream-e2e"
|
||||
cacheRead := streamCacheReadRate
|
||||
prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{
|
||||
Name: name,
|
||||
ProviderId: catalogID,
|
||||
UpstreamUrl: vllm.StreamURL,
|
||||
ApiKey: &dummyKey,
|
||||
Enabled: ptr(true),
|
||||
Models: &[]api.AgentNetworkProviderModel{{
|
||||
Id: streamedModel,
|
||||
InputPer1k: streamInRate,
|
||||
OutputPer1k: streamOutRate,
|
||||
CacheReadPer1k: &cacheRead,
|
||||
}},
|
||||
})
|
||||
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-" + name,
|
||||
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, name, sk.Key)
|
||||
return pricedEnv{
|
||||
providerID: prov.Id,
|
||||
groupID: grp.Id,
|
||||
policyID: pol.Id,
|
||||
upstream: vllm.StreamURL,
|
||||
endpoint: endpoint,
|
||||
proxyIP: proxyIP,
|
||||
client: cl,
|
||||
proxy: px,
|
||||
}
|
||||
}
|
||||
|
||||
// chatStreamUntil drives one streamed chat, retrying to absorb the tunnel and
|
||||
// DNS jitter a first call through a fresh peer can hit.
|
||||
func chatStreamUntil(t *testing.T, ctx context.Context, env pricedEnv, kind, model, sessionID string) (int, string) {
|
||||
t.Helper()
|
||||
var code int
|
||||
var body string
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c, b, cerr := env.client.ChatStream(ctx, env.endpoint, env.proxyIP, kind, model, "Reply with exactly: pong", sessionID)
|
||||
if cerr == nil {
|
||||
code, body = c, b
|
||||
if code == 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
if code != 200 {
|
||||
t.Logf("=== proxy logs ===\n%s", env.proxy.Logs(context.Background()))
|
||||
}
|
||||
return code, body
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -292,6 +293,27 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi
|
||||
return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID))
|
||||
}
|
||||
|
||||
// ChatStream is Chat with "stream": true in the request body, so the proxy's
|
||||
// request parser marks the call as streaming and its response parser takes the
|
||||
// SSE accumulator rather than the buffered-body path. Pair it with a provider
|
||||
// pointed at VLLM.StreamURL, which answers every request as an event stream.
|
||||
func (cl *Client) ChatStream(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) {
|
||||
var path, body string
|
||||
var headers []string
|
||||
switch kind {
|
||||
case WireMessages:
|
||||
path = "/v1/messages"
|
||||
headers = []string{"anthropic-version: 2023-06-01"}
|
||||
body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"stream":true,"messages":[{"role":"user","content":%q}]}`, model, prompt)
|
||||
default:
|
||||
path = "/v1/chat/completions"
|
||||
// include_usage is what makes a real OpenAI stream emit its final usage
|
||||
// frame; without it the last chunk carries no tokens at all.
|
||||
body = fmt.Sprintf(`{"model":%q,"stream":true,"stream_options":{"include_usage":true},"messages":[{"role":"user","content":%q}]}`, model, prompt)
|
||||
}
|
||||
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID))
|
||||
}
|
||||
|
||||
// Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike
|
||||
// Chat, the model is carried in the request path (project/region/model), so the
|
||||
// proxy routes by path and mints the service-account OAuth token; the body uses
|
||||
@@ -322,10 +344,29 @@ func withSessionID(headers []string, sessionID string) []string {
|
||||
return append(headers, "x-session-id: "+sessionID)
|
||||
}
|
||||
|
||||
// post runs curl in a throwaway container sharing the client's network
|
||||
// namespace so the request traverses the WireGuard tunnel, pinning the endpoint
|
||||
// to the proxy IP. It returns the HTTP status and response body.
|
||||
// Get issues a GET to the agent-network endpoint over the client's tunnel.
|
||||
// Model discovery and the connection-warming probe are read-only endpoints
|
||||
// that carry no body, so they can't go through the chat helpers.
|
||||
func (cl *Client) Get(ctx context.Context, endpoint, proxyIP, path string, extraHeaders []string) (int, string, error) {
|
||||
return cl.do(ctx, http.MethodGet, endpoint, proxyIP, path, "", extraHeaders)
|
||||
}
|
||||
|
||||
// PostJSON issues an arbitrary JSON POST over the client's tunnel, for wire
|
||||
// shapes the typed helpers don't cover (token counting, say).
|
||||
func (cl *Client) PostJSON(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) {
|
||||
return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders)
|
||||
}
|
||||
|
||||
// post issues a JSON POST. Retained as the shorthand the chat helpers use.
|
||||
func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) {
|
||||
return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders)
|
||||
}
|
||||
|
||||
// do runs curl in a throwaway container sharing the client's network
|
||||
// namespace so the request traverses the WireGuard tunnel, pinning the endpoint
|
||||
// to the proxy IP. It returns the HTTP status and response body. An empty body
|
||||
// sends no payload, which is what a GET needs.
|
||||
func (cl *Client) do(ctx context.Context, method, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) {
|
||||
url := "https://" + endpoint + path
|
||||
args := []string{
|
||||
"run", "--rm",
|
||||
@@ -334,13 +375,15 @@ func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string
|
||||
"-sk", "--connect-timeout", "5", "--max-time", "90",
|
||||
"--resolve", endpoint + ":443:" + proxyIP,
|
||||
"-o", "/dev/stderr", "-w", "%{http_code}",
|
||||
"-X", "POST", url,
|
||||
"-X", method, url,
|
||||
"-H", "Content-Type: application/json",
|
||||
}
|
||||
for _, h := range extraHeaders {
|
||||
args = append(args, "-H", h)
|
||||
}
|
||||
args = append(args, "--data", body)
|
||||
if body != "" {
|
||||
args = append(args, "--data", body)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
// -w writes the status code to stdout; -o /dev/stderr writes the body to
|
||||
// stderr so we can capture both separately.
|
||||
|
||||
@@ -18,18 +18,63 @@ const (
|
||||
vllmImage = "nginx:alpine"
|
||||
vllmAlias = "vllm"
|
||||
vllmPort = "8000/tcp"
|
||||
// vllmStreamPort serves the same wire shapes as an SSE stream. See the
|
||||
// nginx config for why streaming lives on its own listener.
|
||||
vllmStreamPort = "8001/tcp"
|
||||
// VLLMModel is the served model id the mock advertises and echoes back. It
|
||||
// matches a real small model commonly served by vLLM so the provider's
|
||||
// enumerated model and the client's request line up.
|
||||
VLLMModel = "Qwen/Qwen2.5-0.5B-Instruct"
|
||||
// VLLMUnlistedModel is a second id the mock's model listing advertises but
|
||||
// no test provider enumerates, so a filtered listing is observably shorter
|
||||
// than the upstream's own.
|
||||
VLLMUnlistedModel = "Qwen/Qwen2.5-7B-Instruct"
|
||||
)
|
||||
|
||||
// Token counts the mock reports per wire shape. Tests assert on these rather
|
||||
// than on "> 0" so a response parsed with the wrong provider's parser (which
|
||||
// would read a different field, or none) fails loudly instead of passing on
|
||||
// a coincidental non-zero.
|
||||
const (
|
||||
// VLLMChatInputTokens / VLLMChatOutputTokens ride the OpenAI usage block.
|
||||
VLLMChatInputTokens = 11
|
||||
VLLMChatOutputTokens = 2
|
||||
// VLLMMessagesInputTokens / VLLMMessagesOutputTokens ride the Anthropic
|
||||
// usage block, whose field names the OpenAI parser cannot read.
|
||||
VLLMMessagesInputTokens = 17
|
||||
VLLMMessagesOutputTokens = 3
|
||||
)
|
||||
|
||||
// Token counts the streaming surface reports. They differ from the
|
||||
// non-streaming ones on purpose: a test that asserts these numbers proves the
|
||||
// SSE accumulator ran, rather than a buffered JSON body having been parsed.
|
||||
//
|
||||
// Input and cache-read arrive on message_start; output arrives on
|
||||
// message_delta and supersedes the seed value message_start carries. Any
|
||||
// parser that cannot read message_start reports zero input tokens — which is
|
||||
// exactly the bug these counts exist to catch.
|
||||
const (
|
||||
VLLMStreamInputTokens = 29
|
||||
VLLMStreamOutputTokens = 5
|
||||
VLLMStreamCacheReadTokens = 7
|
||||
)
|
||||
|
||||
// vllmNginxConf emulates a vLLM OpenAI-compatible server over plain HTTP (vLLM's
|
||||
// default: no TLS, port 8000). It answers /v1/models with a one-model list and
|
||||
// any chat/completions path with a canned OpenAI-shaped chat completion carrying
|
||||
// a non-zero usage block, so the proxy's OpenAI parser records real token
|
||||
// consumption. Running actual vLLM in CI is infeasible (GPU + multi-GB model
|
||||
// default: no TLS, port 8000), and additionally answers the wire shapes the
|
||||
// other catalog surfaces speak so one mock can stand in for every provider the
|
||||
// proxy routes to. Running actual vLLM in CI is infeasible (GPU + multi-GB model
|
||||
// download), so this stands in for the wire contract the proxy depends on.
|
||||
//
|
||||
// Each shape answers with its own vendor's usage block, so a response parsed
|
||||
// under the wrong surface meters zero rather than passing by accident:
|
||||
//
|
||||
// - /v1/chat/completions (and any unmatched path): OpenAI chat completion.
|
||||
// - /v1/messages: Anthropic Messages, snake_case usage plus a cache bucket.
|
||||
// - /model/{id}/invoke: Bedrock InvokeModel, which carries the Anthropic body.
|
||||
// - the token-counting endpoints: a count, with no usage block at all.
|
||||
//
|
||||
// The model listing advertises two models so a policy that authorises one
|
||||
// produces an observably shorter list than the upstream's own.
|
||||
const vllmNginxConf = `pid /tmp/nginx.pid;
|
||||
events {}
|
||||
http {
|
||||
@@ -37,13 +82,75 @@ http {
|
||||
listen 8000;
|
||||
location = /v1/models {
|
||||
default_type application/json;
|
||||
return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"}]}';
|
||||
return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"},{"id":"Qwen/Qwen2.5-7B-Instruct","object":"model","owned_by":"vllm"}]}';
|
||||
}
|
||||
location = /v1/messages {
|
||||
default_type application/json;
|
||||
return 200 '{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-sonnet-5","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}';
|
||||
}
|
||||
location = /v1/messages/count_tokens {
|
||||
default_type application/json;
|
||||
return 200 '{"input_tokens":7}';
|
||||
}
|
||||
location ~ ^/model/.+/invoke$ {
|
||||
default_type application/json;
|
||||
return 200 '{"id":"msg_e2e_bedrock","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}';
|
||||
}
|
||||
location ~ ^/model/.+/count-tokens$ {
|
||||
default_type application/json;
|
||||
return 200 '{"inputTokens":9}';
|
||||
}
|
||||
location = /api/hello {
|
||||
return 200;
|
||||
}
|
||||
location = /inference-profiles {
|
||||
default_type application/json;
|
||||
return 200 '{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-sonnet-5","status":"ACTIVE"}]}';
|
||||
}
|
||||
location / {
|
||||
default_type application/json;
|
||||
return 200 '{"id":"chatcmpl-e2e-vllm","object":"chat.completion","created":1700000000,"model":"Qwen/Qwen2.5-0.5B-Instruct","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":2,"total_tokens":13}}';
|
||||
}
|
||||
}
|
||||
|
||||
# The streaming surface, on its own port so the response content type is a
|
||||
# property of the listener rather than of a per-request branch: nginx sets
|
||||
# Content-Type from default_type, which cannot be varied inside an "if", and
|
||||
# a second Content-Type via add_header would leave the proxy reading the
|
||||
# wrong one. A provider record pointed at this port streams every answer.
|
||||
#
|
||||
# Input and cache-read tokens ride message_start, output rides message_delta
|
||||
# — the split that makes a stream different from a buffered body, and the
|
||||
# reason a parser that ignores message_start meters input as zero.
|
||||
server {
|
||||
listen 8001;
|
||||
location = /v1/messages {
|
||||
default_type text/event-stream;
|
||||
return 200 'event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_e2e_stream","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":29,"output_tokens":1,"cache_read_input_tokens":7}}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
';
|
||||
}
|
||||
location / {
|
||||
default_type text/event-stream;
|
||||
return 200 'data: {"choices":[{"delta":{"content":"pong"}}]}
|
||||
|
||||
data: {"choices":[],"usage":{"prompt_tokens":29,"completion_tokens":5,"total_tokens":34}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
';
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -55,6 +162,10 @@ type VLLM struct {
|
||||
workDir string
|
||||
// URL is the upstream URL the vllm provider points at (http://<alias>:8000).
|
||||
URL string
|
||||
// StreamURL is the same mock's streaming listener. A provider pointed here
|
||||
// answers every request as SSE, so the proxy's streaming accumulator runs
|
||||
// instead of its buffered-body parser.
|
||||
StreamURL string
|
||||
}
|
||||
|
||||
// StartVLLM runs the mock vLLM server on the shared network over plain HTTP.
|
||||
@@ -73,14 +184,17 @@ func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) {
|
||||
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: vllmImage,
|
||||
ExposedPorts: []string{vllmPort},
|
||||
ExposedPorts: []string{vllmPort, vllmStreamPort},
|
||||
Networks: []string{c.network.Name},
|
||||
NetworkAliases: map[string][]string{c.network.Name: {vllmAlias}},
|
||||
Cmd: []string{"nginx", "-c", "/conf/nginx.conf", "-g", "daemon off;"},
|
||||
HostConfigModifier: func(hc *container.HostConfig) {
|
||||
hc.Binds = append(hc.Binds, workDir+":/conf:ro")
|
||||
},
|
||||
WaitingFor: wait.ForListeningPort(vllmPort).WithStartupTimeout(60 * time.Second),
|
||||
WaitingFor: wait.ForAll(
|
||||
wait.ForListeningPort(vllmPort),
|
||||
wait.ForListeningPort(vllmStreamPort),
|
||||
).WithStartupTimeout(60 * time.Second),
|
||||
}
|
||||
|
||||
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
@@ -92,7 +206,12 @@ func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) {
|
||||
return nil, fmt.Errorf("start vllm container: %w", err)
|
||||
}
|
||||
|
||||
return &VLLM{container: ctr, workDir: workDir, URL: "http://" + vllmAlias + ":8000"}, nil
|
||||
return &VLLM{
|
||||
container: ctr,
|
||||
workDir: workDir,
|
||||
URL: "http://" + vllmAlias + ":8000",
|
||||
StreamURL: "http://" + vllmAlias + ":8001",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logs returns the vLLM container logs, for diagnostics on failure.
|
||||
|
||||
12
funding.json
12
funding.json
@@ -6,7 +6,7 @@
|
||||
"name": "NetBird GmbH",
|
||||
"email": "hello@netbird.io",
|
||||
"phone": "",
|
||||
"description": "NetBird GmbH is a Berlin-based software company specializing in the development of open source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.",
|
||||
"description": "NetBird GmbH is a Berlin-based software company specializing in the development of open-source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open-source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.",
|
||||
"webpageUrl": {
|
||||
"url": "https://github.com/netbirdio"
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
{
|
||||
"guid": "netbird",
|
||||
"name": "NetBird",
|
||||
"description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.",
|
||||
"description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open-source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.",
|
||||
"webpageUrl": {
|
||||
"url": "https://github.com/netbirdio/netbird"
|
||||
},
|
||||
@@ -59,7 +59,7 @@
|
||||
"guid": "support-yearly",
|
||||
"status": "active",
|
||||
"name": "Support Open Source Development and Maintenance - Yearly",
|
||||
"description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.",
|
||||
"description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.",
|
||||
"amount": 100000,
|
||||
"currency": "USD",
|
||||
"frequency": "yearly",
|
||||
@@ -72,7 +72,7 @@
|
||||
"guid": "support-one-time-year",
|
||||
"status": "active",
|
||||
"name": "Support Open Source Development and Maintenance - One Year",
|
||||
"description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.",
|
||||
"description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.",
|
||||
"amount": 100000,
|
||||
"currency": "USD",
|
||||
"frequency": "one-time",
|
||||
@@ -85,7 +85,7 @@
|
||||
"guid": "support-one-time-monthly",
|
||||
"status": "active",
|
||||
"name": "Support Open Source Development and Maintenance - Monthly",
|
||||
"description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.",
|
||||
"description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.",
|
||||
"amount": 10000,
|
||||
"currency": "USD",
|
||||
"frequency": "monthly",
|
||||
@@ -98,7 +98,7 @@
|
||||
"guid": "support-monthly",
|
||||
"status": "active",
|
||||
"name": "Support Open Source Development and Maintenance - One Month",
|
||||
"description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.",
|
||||
"description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.",
|
||||
"amount": 10000,
|
||||
"currency": "USD",
|
||||
"frequency": "monthly",
|
||||
|
||||
12
go.mod
12
go.mod
@@ -19,7 +19,7 @@ require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
@@ -127,9 +127,9 @@ require (
|
||||
go.uber.org/zap v1.27.0
|
||||
goauthentik.io/api/v3 v3.2023051.3
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||
golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733
|
||||
golang.org/x/mod v0.39.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/term v0.45.0
|
||||
@@ -313,8 +313,8 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||
|
||||
24
go.sum
24
go.sum
@@ -728,13 +728,13 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733 h1:XKMObIaAElmkdO+4SQh1iCfzwciZHJi1OblnX9BED9k=
|
||||
golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733/go.mod h1:jMwjxoDSx9jqhNaZqPnr6nnKzb7cs+Dy1Czk7wdX+R8=
|
||||
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab h1:Iqyc+2zr7aGyLuEadIm0KRJP0Wwt+fhlXLa51Fxf1+Q=
|
||||
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab/go.mod h1:Eq3Nh/5pFSWug2ohiudJ1iyU59SO78QFuh4qTTN++I0=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -744,8 +744,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
@@ -764,8 +764,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
@@ -843,8 +843,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -858,8 +858,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -221,6 +221,9 @@ render_override() {
|
||||
# Remove this file (and config.yaml.enterprise if present) to revert.
|
||||
|
||||
services:
|
||||
${DASHBOARD_SERVICE}:
|
||||
image: \${NETBIRD_DASHBOARD_IMAGE:-ghcr.io/netbirdio/dashboard-cloud:latest}
|
||||
|
||||
${COMBINED_SERVICE}:
|
||||
image: \${NETBIRD_SERVER_IMAGE:-ghcr.io/netbirdio/netbird-server-cloud:latest}
|
||||
environment:
|
||||
|
||||
@@ -296,6 +296,8 @@ var providers = []Provider{
|
||||
// account to be on >= 30-day data retention or all requests
|
||||
// 400.
|
||||
Models: []Model{
|
||||
{ID: "claude-opus-5", Label: "Claude Opus 5", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-sonnet-5", Label: "Claude Sonnet 5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
|
||||
{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
@@ -355,6 +357,8 @@ var providers = []Provider{
|
||||
// Llama 3.3 70B entry kept unchanged — LiteLLM tracks only
|
||||
// per-region Llama 3 entries; standalone 3.3 not yet listed.
|
||||
Models: []Model{
|
||||
{ID: "anthropic.claude-opus-5", Label: "Claude Opus 5 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-sonnet-5", Label: "Claude Sonnet 5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
@@ -406,6 +410,8 @@ var providers = []Provider{
|
||||
// exists — the router denies unmeterable publishers rather than forward
|
||||
// them uncounted.
|
||||
Models: []Model{
|
||||
{ID: "claude-opus-5", Label: "Claude Opus 5 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-sonnet-5", Label: "Claude Sonnet 5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
|
||||
{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestClaudeLineupSelectable pins the models Claude Code resolves to by
|
||||
// default. A model absent from the lineup can't be ticked on a provider
|
||||
// record, so llm_router denies it as not-routable and the operator has no
|
||||
// way to authorise the client's own default.
|
||||
func TestClaudeLineupSelectable(t *testing.T) {
|
||||
for providerID, wanted := range map[string][]string{
|
||||
"anthropic_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
|
||||
"bedrock_api": {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5", "anthropic.claude-haiku-4-5"},
|
||||
"vertex_ai_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
|
||||
} {
|
||||
provider, ok := Lookup(providerID)
|
||||
require.True(t, ok, "catalog must define %s", providerID)
|
||||
|
||||
selectable := make(map[string]Model, len(provider.Models))
|
||||
for _, m := range provider.Models {
|
||||
selectable[m.ID] = m
|
||||
}
|
||||
for _, id := range wanted {
|
||||
model, found := selectable[id]
|
||||
require.True(t, found, "%s must offer %s", providerID, id)
|
||||
assert.NotEmpty(t, model.Label, "%s/%s needs a label for the picker", providerID, id)
|
||||
assert.Positive(t, model.InputPer1k, "%s/%s needs an input rate", providerID, id)
|
||||
assert.Positive(t, model.OutputPer1k, "%s/%s needs an output rate", providerID, id)
|
||||
assert.Positive(t, model.ContextWindow, "%s/%s needs a context window", providerID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,17 +47,11 @@ var supplementalDefaults = map[string]map[string]Entry{
|
||||
"gpt-5-nano": {InputPer1k: 0.00005, OutputPer1k: 0.0004, CachedInputPer1k: 0.000005},
|
||||
},
|
||||
"anthropic": {
|
||||
// claude-opus-5 is not yet in the catalog lineup but gateway /
|
||||
// grandfathered traffic uses it; priced so it isn't skipped.
|
||||
"claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
|
||||
// "kimi-k3[1m]" is the 1M-context alias some Claude Code guides
|
||||
// configure against Moonshot's Anthropic-compatible endpoint;
|
||||
// priced identically to kimi-k3 so those requests aren't skipped.
|
||||
"kimi-k3[1m]": {InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003},
|
||||
},
|
||||
"bedrock": {
|
||||
"anthropic.claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -82,6 +82,11 @@ anthropic:
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
claude-sonnet-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
kimi-k3:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
@@ -145,6 +150,11 @@ bedrock:
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
anthropic.claude-sonnet-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
meta.llama3-3-70b-instruct:
|
||||
input_per_1k: 0.00072
|
||||
output_per_1k: 0.00072
|
||||
|
||||
@@ -116,11 +116,13 @@ func TestDefaultTable_PinnedRates(t *testing.T) {
|
||||
assert.InDelta(t, 0.010, fable.InputPer1k, 1e-9, "claude-fable-5 input")
|
||||
assert.InDelta(t, 0.0125, fable.CacheCreationPer1k, 1e-9, "claude-fable-5 cache creation")
|
||||
|
||||
// Supplementals present on their surfaces.
|
||||
// Every id below must stay priced whichever source provides it: the
|
||||
// catalog lineup for the current Claude 5 family, supplementalDefaults
|
||||
// for the ids the dashboard deliberately doesn't offer.
|
||||
for surface, ids := range map[string][]string{
|
||||
"openai": {"gpt-5", "gpt-5-mini", "gpt-5-nano"},
|
||||
"anthropic": {"claude-opus-5", "kimi-k3[1m]", "kimi-k3"},
|
||||
"bedrock": {"anthropic.claude-opus-5"},
|
||||
"anthropic": {"claude-opus-5", "claude-sonnet-5", "kimi-k3[1m]", "kimi-k3"},
|
||||
"bedrock": {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5"},
|
||||
} {
|
||||
for _, id := range ids {
|
||||
_, ok := table[surface][id]
|
||||
|
||||
@@ -13,6 +13,14 @@ func NormalizeBedrockModel(modelID string) string {
|
||||
return sharedllm.NormalizeBedrockModel(modelID)
|
||||
}
|
||||
|
||||
// NormalizeAnthropicModel strips the trailing "-YYYYMMDD" release-date suffix
|
||||
// from an Anthropic model id so a dated id a client pins matches the undated
|
||||
// one the operator registered. Thin delegate to shared/llm for the same
|
||||
// contract reason as the two below.
|
||||
func NormalizeAnthropicModel(modelID string) string {
|
||||
return sharedllm.NormalizeAnthropicModel(modelID)
|
||||
}
|
||||
|
||||
// NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id
|
||||
// so it matches the catalog/pricing key. Thin delegate to shared/llm, kept
|
||||
// beside NormalizeBedrockModel for the same contract reason.
|
||||
|
||||
@@ -10,6 +10,8 @@ package pricing
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
sharedllm "github.com/netbirdio/netbird/shared/llm"
|
||||
)
|
||||
|
||||
// Entry is a single model's input and output pricing, expressed in USD per
|
||||
@@ -92,7 +94,10 @@ func NewTable(raw map[string]map[string]EntryJSON) (*Table, error) {
|
||||
return &Table{entries: entries}, nil
|
||||
}
|
||||
|
||||
// Lookup returns the entry for the given provider surface and model.
|
||||
// Lookup returns the entry for the given provider surface and model. A
|
||||
// dated Anthropic id falls back to its undated form, so a client pinning
|
||||
// "claude-sonnet-4-5-20250929" bills at the registered "claude-sonnet-4-5"
|
||||
// rate instead of recording no cost at all.
|
||||
func (t *Table) Lookup(provider, model string) (Entry, bool) {
|
||||
if t == nil {
|
||||
return Entry{}, false
|
||||
@@ -101,7 +106,14 @@ func (t *Table) Lookup(provider, model string) (Entry, bool) {
|
||||
if !ok {
|
||||
return Entry{}, false
|
||||
}
|
||||
e, ok := byModel[model]
|
||||
if e, found := byModel[model]; found {
|
||||
return e, true
|
||||
}
|
||||
undated := sharedllm.NormalizeAnthropicModel(model)
|
||||
if undated == model {
|
||||
return Entry{}, false
|
||||
}
|
||||
e, ok := byModel[undated]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -175,3 +175,22 @@ func TestNewTable_NilAndEmpty(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries, "nil in, empty (never-matching) map out for the per-record map")
|
||||
}
|
||||
|
||||
// TestLookup_DatedAnthropicIDFallsBackToUndated covers a client pinning a
|
||||
// release date on a model priced under its undated id. Without the
|
||||
// fallback the request records no cost at all.
|
||||
func TestLookup_DatedAnthropicIDFallsBackToUndated(t *testing.T) {
|
||||
table, err := NewTable(map[string]map[string]EntryJSON{
|
||||
"anthropic": {
|
||||
"claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "table must build from a valid defaults map")
|
||||
|
||||
entry, ok := table.Lookup("anthropic", "claude-sonnet-4-5-20250929")
|
||||
require.True(t, ok, "a dated id must resolve to the undated entry")
|
||||
assert.InDelta(t, 0.003, entry.InputPer1K, 1e-9, "dated id must bill at the registered rate")
|
||||
|
||||
_, ok = table.Lookup("anthropic", "claude-sonnet-9-9-20250929")
|
||||
assert.False(t, ok, "an unknown family must stay unpriced")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/llm"
|
||||
"github.com/netbirdio/netbird/proxy/internal/llm/pricing"
|
||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||
)
|
||||
@@ -175,13 +176,28 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar
|
||||
// Anthropic route still bills its cache buckets additively.
|
||||
func (m *Middleware) lookupCosts(md []middleware.KV, surface, model string, inTokens, outTokens, cachedTokens, cacheCreationTokens int64) (pricing.Costs, bool) {
|
||||
if recordID := lookupKV(md, middleware.KeyLLMResolvedProviderID); recordID != "" {
|
||||
if entry, ok := m.perRecord[recordID][model]; ok {
|
||||
if entry, ok := perRecordEntry(m.perRecord[recordID], model); ok {
|
||||
return pricing.EntryCosts(entry, surface, inTokens, outTokens, cachedTokens, cacheCreationTokens), true
|
||||
}
|
||||
}
|
||||
return m.defaults.Costs(surface, model, inTokens, outTokens, cachedTokens, cacheCreationTokens)
|
||||
}
|
||||
|
||||
// perRecordEntry resolves the operator's stored price for a model on one
|
||||
// provider record, falling back to the undated form of a dated Anthropic id
|
||||
// so a client that pins a release date still bills at the registered rate.
|
||||
func perRecordEntry(byModel map[string]pricing.Entry, model string) (pricing.Entry, bool) {
|
||||
if entry, ok := byModel[model]; ok {
|
||||
return entry, true
|
||||
}
|
||||
undated := llm.NormalizeAnthropicModel(model)
|
||||
if undated == model {
|
||||
return pricing.Entry{}, false
|
||||
}
|
||||
entry, ok := byModel[undated]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
// usd renders a cost as the fixed-precision string every cost.usd_* key
|
||||
// carries, so the per-bucket values and the aggregates round identically.
|
||||
//
|
||||
|
||||
@@ -84,8 +84,10 @@ func (m *Middleware) MutationsSupported() bool { return false }
|
||||
func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
|
||||
model, modelPresent := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
||||
providerID, _ := lookupMetadata(in.Metadata, middleware.KeyLLMResolvedProviderID)
|
||||
surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
|
||||
nonInference, _ := lookupMetadata(in.Metadata, middleware.KeyLLMNonInference)
|
||||
|
||||
if denial := m.evaluateAllowlist(providerID, model, modelPresent); denial != nil {
|
||||
if denial := m.evaluateAllowlist(providerID, surface, model, modelPresent, nonInference == "true"); denial != nil {
|
||||
return denial, nil
|
||||
}
|
||||
|
||||
@@ -114,7 +116,7 @@ func (m *Middleware) Close() error { return nil }
|
||||
// evaluateAllowlist denies when the resolved provider's allowlist rejects the
|
||||
// model; nil means proceed. Scoped to the provider llm_router resolved, so an
|
||||
// unrestricted provider (absent from config) is never caught by another's list.
|
||||
func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bool) *middleware.Output {
|
||||
func (m *Middleware) evaluateAllowlist(providerID, surface, model string, modelPresent, nonInference bool) *middleware.Output {
|
||||
if len(m.cfg.ProviderAllowlists) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -122,7 +124,7 @@ func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bo
|
||||
// if this request targets a restricted provider — fail closed. llm_router
|
||||
// normally stamps the provider first, so this is a defensive guard.
|
||||
if providerID == "" {
|
||||
return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
|
||||
return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
|
||||
}
|
||||
allowlist, restricted := m.cfg.ProviderAllowlists[providerID]
|
||||
if !restricted {
|
||||
@@ -133,18 +135,29 @@ func (m *Middleware) evaluateAllowlist(providerID, model string, modelPresent bo
|
||||
// Fail closed: with an allowlist in effect for this provider, a request whose
|
||||
// model the parser couldn't extract (absent/empty) is denied. This enforces
|
||||
// the allowlist for path-routed providers (Bedrock, Vertex) with no body model.
|
||||
//
|
||||
// The exception is a non-inference endpoint the router already authorised.
|
||||
// The model listing and the connection-warming probe name no model
|
||||
// anywhere — not in a body, not in the path — so failing closed here
|
||||
// rejected model discovery for exactly the accounts that configured an
|
||||
// allowlist, which is the outage this endpoint is meant to avoid. The
|
||||
// per-model lookup does name one (the router stamps it from the path), so
|
||||
// it still falls through to the allowlist check below.
|
||||
if !modelPresent || normaliseModel(model) == "" {
|
||||
return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
|
||||
if nonInference {
|
||||
return nil
|
||||
}
|
||||
return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
|
||||
}
|
||||
if modelInAllowlist(allowlist, model) {
|
||||
return nil
|
||||
}
|
||||
return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel)
|
||||
return denyModel(surface, model, denyCodeModel, denyMessageModel, denyReasonModel)
|
||||
}
|
||||
|
||||
// denyModel builds a 403 deny Output for a model-allowlist rejection. model is
|
||||
// included in the details only when non-empty.
|
||||
func denyModel(model, code, message, reason string) *middleware.Output {
|
||||
func denyModel(surface, model, code, message, reason string) *middleware.Output {
|
||||
details := map[string]string{}
|
||||
if model != "" {
|
||||
details["model"] = model
|
||||
@@ -156,6 +169,7 @@ func denyModel(model, code, message, reason string) *middleware.Output {
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
Surface: surface,
|
||||
},
|
||||
Metadata: []middleware.KV{
|
||||
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
||||
|
||||
@@ -343,3 +343,52 @@ func TestFactoryNormalisesAllowlist(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out2.Decision, "trimmed entry must still match")
|
||||
}
|
||||
|
||||
// TestAllowlistSkipsNonInferenceWithoutModel covers the reported regression:
|
||||
// GET /v1/models carries no model anywhere, so the fail-closed rule above
|
||||
// denied model discovery for exactly the accounts that configured a provider
|
||||
// allowlist — the clients that read a 403 here render an empty model picker.
|
||||
// The router authorises those endpoints by path before the guardrail sees
|
||||
// them, so an absent model there is expected rather than undeterminable.
|
||||
func TestAllowlistSkipsNonInferenceWithoutModel(t *testing.T) {
|
||||
mw := New(providerCfg("gpt-4o"))
|
||||
out, err := mw.Invoke(context.Background(), newInputProvider(testProvider,
|
||||
middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
||||
"model discovery must not be refused because it names no model")
|
||||
}
|
||||
|
||||
// TestAllowlistStillAppliesToNonInferenceWithModel pins that the exemption is
|
||||
// scoped to requests that genuinely name nothing. The per-model lookup
|
||||
// (GET /v1/models/{id}) is non-inference too, but the router stamps the model
|
||||
// from its path, so the allowlist must still decide it — otherwise the
|
||||
// exemption becomes a way to confirm a model the policy blocks.
|
||||
func TestAllowlistStillAppliesToNonInferenceWithModel(t *testing.T) {
|
||||
mw := New(providerCfg("gpt-4o"))
|
||||
|
||||
t.Run("model in the allowlist", func(t *testing.T) {
|
||||
out, err := mw.Invoke(context.Background(), newInputProvider(testProvider,
|
||||
middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"},
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
||||
"an allowlisted model must stay reachable")
|
||||
})
|
||||
|
||||
t.Run("model outside the allowlist", func(t *testing.T) {
|
||||
out, err := mw.Invoke(context.Background(), newInputProvider(testProvider,
|
||||
middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"},
|
||||
middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-5"},
|
||||
))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
||||
"non-inference must not become a way past the allowlist")
|
||||
require.NotNil(t, out.DenyReason)
|
||||
assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code,
|
||||
"a named but blocked model is blocked, not unknown")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -217,6 +217,32 @@ func applyHeaderPair(rule *HeaderPairRule, in *middleware.Input) *middleware.Mut
|
||||
return mutations
|
||||
}
|
||||
|
||||
// bodyInjectableSurfaces are the request-body dialects that accept the
|
||||
// OpenAI-standard identity fields this middleware writes. A surface
|
||||
// outside this set gets header-only stamping: "user" and "metadata.tags"
|
||||
// are not part of the Anthropic Messages schema, which rejects unknown
|
||||
// top-level fields and permits only "user_id" under metadata, so writing
|
||||
// them into an Anthropic-shaped body turns a working request into a 400.
|
||||
// Claude Code speaks that shape through gateway records pinned to the
|
||||
// OpenAI parser, so the check keys on the detected surface rather than
|
||||
// on the provider record.
|
||||
var bodyInjectableSurfaces = map[string]struct{}{
|
||||
"openai": {},
|
||||
// An empty surface means no parser claimed the path (a custom gateway
|
||||
// base). Those upstreams are OpenAI-compatible by convention, so keep
|
||||
// the long-standing behaviour rather than silently dropping identity.
|
||||
"": {},
|
||||
}
|
||||
|
||||
// bodyAcceptsOpenAIIdentity reports whether the request body may carry the
|
||||
// OpenAI-standard identity fields, read from the surface llm_request_parser
|
||||
// resolved from the request path.
|
||||
func bodyAcceptsOpenAIIdentity(in *middleware.Input) bool {
|
||||
surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
|
||||
_, ok := bodyInjectableSurfaces[surface]
|
||||
return ok
|
||||
}
|
||||
|
||||
// injectIntoBody parses the request body and writes the supplied
|
||||
// identity dimensions into it. Tags land at metadata.tags (creating
|
||||
// the metadata object when absent); the user identity lands at the
|
||||
@@ -225,6 +251,8 @@ func applyHeaderPair(rule *HeaderPairRule, in *middleware.Input) *middleware.Mut
|
||||
// was written. Returns ok=false (no mutation) when:
|
||||
//
|
||||
// - both inputs are empty (nothing to write);
|
||||
// - the body speaks a dialect without these fields (see
|
||||
// bodyInjectableSurfaces);
|
||||
// - the body is empty or truncated (we don't have the full document
|
||||
// to safely round-trip);
|
||||
// - the body isn't a JSON object (skip silently — this middleware
|
||||
@@ -245,6 +273,9 @@ func injectIntoBody(in *middleware.Input, tags []string, userID string) ([]byte,
|
||||
if in == nil || len(in.Body) == 0 || in.BodyTruncated {
|
||||
return nil, false
|
||||
}
|
||||
if !bodyAcceptsOpenAIIdentity(in) {
|
||||
return nil, false
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(in.Body, &doc); err != nil {
|
||||
return nil, false
|
||||
|
||||
@@ -704,3 +704,57 @@ func TestInject_ExtraHeaders_EmptyValueSkipped(t *testing.T) {
|
||||
"empty extra value must not be stamped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInject_AnthropicBodyIsNotRewritten pins the shape gate. Claude Code
|
||||
// reaches a LiteLLM record on /v1/messages, where "user" is not a
|
||||
// permitted top-level field and metadata accepts only "user_id", so
|
||||
// writing the OpenAI-standard fields would turn a working request into a
|
||||
// 400 naming a field the client never sent. Header stamping still runs, so
|
||||
// spend tracking and per-end-user budgets keep working.
|
||||
func TestInject_AnthropicBodyIsNotRewritten(t *testing.T) {
|
||||
rule := liteLLMRuleWithBody()
|
||||
rule.HeaderPair.EndUserIDInBody = true
|
||||
mw := New(Config{Providers: []ProviderInjection{rule}})
|
||||
|
||||
in := newInput(litellmProvider, "alice", []string{"grp-eng"})
|
||||
in.UserEmail = "alice@example.com"
|
||||
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
|
||||
in.Body = []byte(`{"model":"claude-sonnet-5","messages":[]}`)
|
||||
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.Mutations)
|
||||
assert.Empty(t, out.Mutations.BodyReplace,
|
||||
"an Anthropic-shaped body must reach the upstream unmodified")
|
||||
|
||||
var endUser string
|
||||
for _, kv := range out.Mutations.HeadersAdd {
|
||||
if kv.Key == "x-litellm-end-user-id" {
|
||||
endUser = kv.Value
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "alice@example.com", endUser,
|
||||
"header stamping must still carry identity when body inject is skipped")
|
||||
}
|
||||
|
||||
// TestInject_OpenAIBodyStillRewritten guards the gate against
|
||||
// over-reaching: the OpenAI surface must keep its body-level identity,
|
||||
// which is the only path LiteLLM's tag-budget check reads.
|
||||
func TestInject_OpenAIBodyStillRewritten(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}})
|
||||
|
||||
in := newInput(litellmProvider, "alice", []string{"grp-eng"})
|
||||
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "openai"})
|
||||
in.Body = []byte(`{"model":"gpt-4o-mini","messages":[]}`)
|
||||
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.Mutations)
|
||||
require.NotEmpty(t, out.Mutations.BodyReplace, "the OpenAI surface still gets body tags")
|
||||
|
||||
var doc map[string]any
|
||||
require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc))
|
||||
meta, ok := doc["metadata"].(map[string]any)
|
||||
require.True(t, ok, "metadata must be an object")
|
||||
assert.NotEmpty(t, meta["tags"], "metadata.tags must still be written")
|
||||
}
|
||||
|
||||
@@ -84,6 +84,15 @@ func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middlew
|
||||
return allowNoAttribution(), nil
|
||||
}
|
||||
|
||||
// Model-listing and other non-inference endpoints carry no model, and
|
||||
// management's per-model allowlist fails closed on an empty one. The
|
||||
// router has already authorised the route against the caller's groups
|
||||
// and the request consumes no tokens, so gating it on a model that
|
||||
// cannot exist would only break gateway model discovery.
|
||||
if lookupKV(in.Metadata, middleware.KeyLLMNonInference) == "true" {
|
||||
return allowNoAttribution(), nil
|
||||
}
|
||||
|
||||
providerID := lookupKV(in.Metadata, middleware.KeyLLMResolvedProviderID)
|
||||
if providerID == "" {
|
||||
// llm_router didn't emit a resolved provider id — usually
|
||||
@@ -117,7 +126,7 @@ func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middlew
|
||||
}
|
||||
|
||||
if resp.GetDecision() == "deny" {
|
||||
return denyFromManagement(resp), nil
|
||||
return denyFromManagement(resp, lookupKV(in.Metadata, middleware.KeyLLMProvider)), nil
|
||||
}
|
||||
return allowFromManagement(resp), nil
|
||||
}
|
||||
@@ -161,7 +170,7 @@ func allowFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.O
|
||||
// envelope. The deny code surfaces verbatim through the framework's
|
||||
// fixed JSON template; arbitrary middleware bytes can't reach the
|
||||
// wire.
|
||||
func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Output {
|
||||
func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse, surface string) *middleware.Output {
|
||||
code := resp.GetDenyCode()
|
||||
if code == "" {
|
||||
code = "llm_policy.cap_exceeded"
|
||||
@@ -176,6 +185,7 @@ func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Ou
|
||||
DenyReason: &middleware.DenyReason{
|
||||
Code: code,
|
||||
Message: denyMessageForCode(code),
|
||||
Surface: surface,
|
||||
},
|
||||
Metadata: []middleware.KV{
|
||||
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
||||
|
||||
@@ -224,3 +224,35 @@ func TestMetadataKeys_Allowlist(t *testing.T) {
|
||||
}
|
||||
assert.ElementsMatch(t, want, keys)
|
||||
}
|
||||
|
||||
// TestInvoke_NonInferenceSkipsPreflight covers gateway model discovery:
|
||||
// GET /v1/models carries no model, and management's per-model allowlist
|
||||
// fails closed on an empty one, so a pre-flight would deny discovery for
|
||||
// exactly the accounts that use the model allowlist. The router marks the
|
||||
// request non-inference after authorising the route, and the gate must
|
||||
// then allow without calling management at all.
|
||||
func TestInvoke_NonInferenceSkipsPreflight(t *testing.T) {
|
||||
mgmt := &fakeMgmt{
|
||||
checkResp: &proto.CheckLLMPolicyLimitsResponse{
|
||||
Decision: "deny",
|
||||
DenyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
}
|
||||
m := New(mgmt, nil)
|
||||
|
||||
out := runInvoke(t, m, &middleware.Input{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-bob",
|
||||
UserGroups: []string{"grp-engineers"},
|
||||
Metadata: []middleware.KV{
|
||||
{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"},
|
||||
{Key: middleware.KeyLLMNonInference, Value: "true"},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "model-less endpoints must not be gated on a model")
|
||||
assert.Nil(t, mgmt.checkReq, "no pre-flight may be sent for a non-inference request")
|
||||
|
||||
assert.Empty(t, lookupKV(out.Metadata, middleware.KeyLLMSelectedPolicyID),
|
||||
"no policy is attributed when nothing was metered")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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) {
|
||||
@@ -36,3 +40,25 @@ func TestParseBedrockPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ func (middlewareImpl) MetadataKeys() []string {
|
||||
middleware.KeyLLMRequestPromptRaw,
|
||||
middleware.KeyLLMCaptureTruncated,
|
||||
middleware.KeyLLMSessionID,
|
||||
middleware.KeyLLMAgentID,
|
||||
middleware.KeyLLMParentAgentID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +74,9 @@ func (middlewareImpl) Close() error { return nil }
|
||||
|
||||
// Invoke detects the LLM provider, parses request facts, and emits
|
||||
// metadata. Always returns DecisionAllow; never errors. Provider
|
||||
// selection prefers the configured providerID (synthesiser-stamped on
|
||||
// agent-network targets) so requests routed to a custom upstream URL
|
||||
// still resolve. Falls back to URL sniffing when no providerID is set.
|
||||
// selection prefers the request path, falling back to the configured
|
||||
// providerID (synthesiser-stamped on agent-network targets) so requests
|
||||
// routed to a custom upstream URL still resolve.
|
||||
func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
|
||||
out := &middleware.Output{Decision: middleware.DecisionAllow}
|
||||
if in == nil {
|
||||
@@ -92,9 +94,14 @@ func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middle
|
||||
return m.invokeBedrock(in, br), nil
|
||||
}
|
||||
|
||||
parser, ok := llm.ParserByName(m.providerID)
|
||||
// A path that names an API surface wins over the configured providerID:
|
||||
// a gateway record pinned to "openai" still serves Claude Code on
|
||||
// /v1/messages, and reading that body with the OpenAI parser loses the
|
||||
// Anthropic usage block and prices the request on the wrong surface.
|
||||
// providerID stays the fallback for upstreams whose path says nothing.
|
||||
parser, ok := llm.DetectParser(extractPath(in.URL))
|
||||
if !ok {
|
||||
parser, ok = llm.DetectParser(extractPath(in.URL))
|
||||
parser, ok = llm.ParserByName(m.providerID)
|
||||
}
|
||||
if !ok {
|
||||
return out, nil
|
||||
@@ -116,9 +123,9 @@ func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middle
|
||||
}
|
||||
appendSessionID := func(md []middleware.KV) []middleware.KV {
|
||||
if sessionID != "" {
|
||||
return append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
|
||||
}
|
||||
return md
|
||||
return appendAgentIDs(md, in.Headers)
|
||||
}
|
||||
|
||||
facts, err := parser.ParseRequest(in.Body)
|
||||
@@ -160,6 +167,41 @@ func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middle
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// agentIDHeader and parentAgentIDHeader carry sub-agent attribution: a
|
||||
// coding agent that spawns helpers stamps the spawned agent's id, plus the
|
||||
// spawning agent's when that helper is itself nested. Both are opaque
|
||||
// identifiers rather than content, so they're emitted regardless of the
|
||||
// prompt-collection toggle, the same way the session id is.
|
||||
const (
|
||||
agentIDHeader = "x-claude-code-agent-id"
|
||||
parentAgentIDHeader = "x-claude-code-parent-agent-id"
|
||||
)
|
||||
|
||||
// appendAgentIDs stamps the sub-agent attribution headers onto the metadata
|
||||
// bag, skipping either one the request doesn't carry.
|
||||
func appendAgentIDs(md []middleware.KV, headers []middleware.KV) []middleware.KV {
|
||||
for _, pair := range []struct{ key, header string }{
|
||||
{middleware.KeyLLMAgentID, agentIDHeader},
|
||||
{middleware.KeyLLMParentAgentID, parentAgentIDHeader},
|
||||
} {
|
||||
if v := headerValue(headers, pair.header); v != "" {
|
||||
md = append(md, middleware.KV{Key: pair.key, Value: v})
|
||||
}
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
// headerValue returns the first non-empty value for the named header.
|
||||
// Headers arrive in canonical form, so the match is case-insensitive.
|
||||
func headerValue(headers []middleware.KV, want string) string {
|
||||
for _, kv := range headers {
|
||||
if strings.EqualFold(kv.Key, want) && kv.Value != "" {
|
||||
return kv.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sessionIDHeaders are request header names that may carry a client
|
||||
// session identifier, checked in order, case-insensitively. Matching is
|
||||
// against Go's canonical header form, so use the hyphenated names the
|
||||
@@ -173,10 +215,8 @@ var sessionIDHeaders = []string{"x-claude-code-session-id", "session-id", "x-ses
|
||||
// canonical form, so the match is case-insensitive.
|
||||
func sessionIDFromHeaders(headers []middleware.KV) string {
|
||||
for _, want := range sessionIDHeaders {
|
||||
for _, kv := range headers {
|
||||
if strings.EqualFold(kv.Key, want) && kv.Value != "" {
|
||||
return kv.Value
|
||||
}
|
||||
if v := headerValue(headers, want); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -252,6 +292,12 @@ func parseVertexPath(reqPath string) (vertexRequest, bool) {
|
||||
if c := strings.LastIndex(rest, ":"); c >= 0 {
|
||||
model, action = rest[:c], rest[c+1:]
|
||||
}
|
||||
// Token counting hangs off the model as its own path segment
|
||||
// (".../models/{model}/count-tokens:rawPredict"), so anything past the
|
||||
// first "/" belongs to the method rather than the model id.
|
||||
if slash := strings.Index(model, "/"); slash >= 0 {
|
||||
model = model[:slash]
|
||||
}
|
||||
model = llm.NormalizeVertexModel(model)
|
||||
if model == "" {
|
||||
return vertexRequest{}, false
|
||||
@@ -298,6 +344,7 @@ func (m middlewareImpl) invokeVertex(in *middleware.Input, vx vertexRequest) *mi
|
||||
if sessionID != "" {
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
|
||||
}
|
||||
md = appendAgentIDs(md, in.Headers)
|
||||
|
||||
promptTruncated := false
|
||||
if parser != nil && m.capturePrompt {
|
||||
@@ -345,7 +392,9 @@ func trimBedrockNamespace(reqPath string) string {
|
||||
//
|
||||
// /model/{modelId}/{action}
|
||||
//
|
||||
// action ∈ {invoke, invoke-with-response-stream, converse, converse-stream}.
|
||||
// action ∈ {invoke, invoke-with-response-stream, converse, converse-stream,
|
||||
// count-tokens}. Token counting carries a model and no usage, so it routes
|
||||
// like any other action and meters to zero.
|
||||
// The modelId may be URL-encoded and may carry a cross-region inference-profile
|
||||
// prefix and a version suffix; normalizeBedrockModel strips both so the model
|
||||
// matches catalog pricing.
|
||||
@@ -369,7 +418,7 @@ func parseBedrockPath(reqPath string) (bedrockRequest, bool) {
|
||||
return bedrockRequest{}, false
|
||||
}
|
||||
switch action {
|
||||
case "invoke", "converse":
|
||||
case "invoke", "converse", "count-tokens":
|
||||
return bedrockRequest{model: model}, true
|
||||
case "invoke-with-response-stream", "converse-stream":
|
||||
return bedrockRequest{model: model, stream: true}, true
|
||||
@@ -397,6 +446,7 @@ func (m middlewareImpl) invokeBedrock(in *middleware.Input, br bedrockRequest) *
|
||||
if sessionID != "" {
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID})
|
||||
}
|
||||
md = appendAgentIDs(md, in.Headers)
|
||||
|
||||
promptTruncated := false
|
||||
if parser != nil && m.capturePrompt {
|
||||
|
||||
@@ -45,6 +45,8 @@ func TestMiddleware_StaticSurface(t *testing.T) {
|
||||
middleware.KeyLLMRequestPromptRaw,
|
||||
middleware.KeyLLMCaptureTruncated,
|
||||
middleware.KeyLLMSessionID,
|
||||
middleware.KeyLLMAgentID,
|
||||
middleware.KeyLLMParentAgentID,
|
||||
}
|
||||
assert.Equal(t, expected, keys, "metadata key allowlist must match the spec")
|
||||
}
|
||||
@@ -230,6 +232,31 @@ func TestInvoke_ProviderIDConfigBypassesURLSniff(t *testing.T) {
|
||||
assert.Equal(t, "gpt-4o-mini", model)
|
||||
}
|
||||
|
||||
func TestInvoke_PathSurfaceBeatsProviderIDConfig(t *testing.T) {
|
||||
// Gateway records (LiteLLM, Portkey, OpenRouter) pin provider_id
|
||||
// "openai", but the same record serves Claude Code on /v1/messages.
|
||||
// Parsing that body as OpenAI reads no usage off the Anthropic
|
||||
// response and prices the request on a surface where no claude-*
|
||||
// model exists, so the path has to win.
|
||||
mw, err := Factory{}.New([]byte(`{"provider_id":"openai"}`))
|
||||
require.NoError(t, err, "factory must accept provider_id config")
|
||||
|
||||
out, err := mw.Invoke(context.Background(), &middleware.Input{
|
||||
URL: "/v1/messages",
|
||||
Body: []byte(`{"model":"claude-sonnet-5","stream":true,"messages":[{"role":"user","content":"Hi"}]}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
|
||||
provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider)
|
||||
require.True(t, ok, "provider must be emitted")
|
||||
assert.Equal(t, "anthropic", provider, "the /v1/messages path selects the Anthropic surface")
|
||||
|
||||
model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel)
|
||||
require.True(t, ok, "model must be extracted")
|
||||
assert.Equal(t, "claude-sonnet-5", model)
|
||||
}
|
||||
|
||||
func TestInvoke_UnknownProviderIDFallsBackToURL(t *testing.T) {
|
||||
mw, err := Factory{}.New([]byte(`{"provider_id":"not-a-real-parser"}`))
|
||||
require.NoError(t, err, "factory must accept any provider_id string")
|
||||
@@ -416,3 +443,81 @@ func TestInvoke_NilInputAllows(t *testing.T) {
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "nil input still allows")
|
||||
assert.Empty(t, out.Metadata, "nil input emits no metadata")
|
||||
}
|
||||
|
||||
// TestParseVertexPath_CountTokensKeepsModel covers Vertex token counting,
|
||||
// where the method hangs off the model as its own path segment. Splitting
|
||||
// only on the final colon swallowed "/count-tokens" into the model id, so
|
||||
// the router saw a model no route could claim.
|
||||
func TestParseVertexPath_CountTokensKeepsModel(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
model string
|
||||
stream bool
|
||||
}{
|
||||
"/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5:rawPredict": {model: "claude-sonnet-5"},
|
||||
"/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5:streamRawPredict": {model: "claude-sonnet-5", stream: true},
|
||||
"/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5/count-tokens:rawPredict": {model: "claude-sonnet-5"},
|
||||
"/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-5@20250929/count-tokens:rawPredict": {model: "claude-sonnet-5"},
|
||||
}
|
||||
for path, want := range cases {
|
||||
vx, ok := parseVertexPath(path)
|
||||
require.True(t, ok, "must parse %q", path)
|
||||
assert.Equal(t, want.model, vx.model, "model for %q", path)
|
||||
assert.Equal(t, want.stream, vx.stream, "stream flag for %q", path)
|
||||
assert.Equal(t, "anthropic", vx.publisher, "publisher for %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvoke_EmitsAgentIDs covers sub-agent attribution: several agents run
|
||||
// in parallel inside one session, and without their ids every request in
|
||||
// the session attributes to the session alone.
|
||||
func TestInvoke_EmitsAgentIDs(t *testing.T) {
|
||||
mw := newMiddleware(t)
|
||||
|
||||
t.Run("spawned agent", func(t *testing.T) {
|
||||
out, err := mw.Invoke(context.Background(), &middleware.Input{
|
||||
URL: "/v1/messages",
|
||||
Body: []byte(`{"model":"claude-sonnet-5","messages":[]}`),
|
||||
Headers: []middleware.KV{
|
||||
{Key: "X-Claude-Code-Session-Id", Value: "sess-1"},
|
||||
{Key: "X-Claude-Code-Agent-Id", Value: "agent-7"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
agent, ok := metaValue(t, out.Metadata, middleware.KeyLLMAgentID)
|
||||
require.True(t, ok, "the spawned agent's id must be emitted")
|
||||
assert.Equal(t, "agent-7", agent)
|
||||
|
||||
_, ok = metaValue(t, out.Metadata, middleware.KeyLLMParentAgentID)
|
||||
assert.False(t, ok, "a top-level agent has no parent to emit")
|
||||
})
|
||||
|
||||
t.Run("nested agent", func(t *testing.T) {
|
||||
out, err := mw.Invoke(context.Background(), &middleware.Input{
|
||||
URL: "/v1/messages",
|
||||
Body: []byte(`{"model":"claude-sonnet-5","messages":[]}`),
|
||||
Headers: []middleware.KV{
|
||||
{Key: "X-Claude-Code-Agent-Id", Value: "agent-9"},
|
||||
{Key: "X-Claude-Code-Parent-Agent-Id", Value: "agent-7"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
agent, _ := metaValue(t, out.Metadata, middleware.KeyLLMAgentID)
|
||||
assert.Equal(t, "agent-9", agent)
|
||||
parent, ok := metaValue(t, out.Metadata, middleware.KeyLLMParentAgentID)
|
||||
require.True(t, ok, "a nested agent must carry the spawning agent's id")
|
||||
assert.Equal(t, "agent-7", parent)
|
||||
})
|
||||
|
||||
t.Run("absent on a plain request", func(t *testing.T) {
|
||||
out, err := mw.Invoke(context.Background(), &middleware.Input{
|
||||
URL: "/v1/messages",
|
||||
Body: []byte(`{"model":"claude-sonnet-5","messages":[]}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, ok := metaValue(t, out.Metadata, middleware.KeyLLMAgentID)
|
||||
assert.False(t, ok, "no key is emitted when the client sends no agent id")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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
|
||||
@@ -28,3 +32,86 @@ func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -109,6 +109,10 @@ func (m *Middleware) MetadataKeys() []string {
|
||||
middleware.KeyLLMAuthorisingGroups,
|
||||
middleware.KeyLLMPolicyDecision,
|
||||
middleware.KeyLLMPolicyReason,
|
||||
middleware.KeyLLMNonInference,
|
||||
// Emitted only for the per-model lookup, whose model lives in the path
|
||||
// rather than a body the parser could read.
|
||||
middleware.KeyLLMModel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,29 +141,26 @@ const (
|
||||
// known to a provider that no policy authorises for the caller deny
|
||||
// with no_authorised_provider.
|
||||
func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
|
||||
reqPath := requestPath(in.URL)
|
||||
// The caller's API dialect, used to mirror a denial in the vendor's own
|
||||
// error shape so the client can explain it to the user.
|
||||
surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
|
||||
model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
||||
|
||||
// Vertex AI carries the model in the URL path, not the body, and is
|
||||
// selected by path rather than by the model/vendor table. Route it before
|
||||
// the model lookup so a model the parser extracted from the path can't be
|
||||
// claimed by a same-vendor direct provider (e.g. claude-* on api.anthropic.com).
|
||||
reqPath := requestPath(in.URL)
|
||||
if isVertexPath(reqPath) {
|
||||
model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
||||
// The request parser emits no llm.provider for a Vertex publisher it
|
||||
// can't parse (e.g. google/gemini). Forwarding such a request would
|
||||
// bypass token/budget metering, so deny it rather than serve it
|
||||
// unmetered.
|
||||
if vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider); vendor == "" {
|
||||
return denyUnmeterable(), nil
|
||||
if surface == "" {
|
||||
return denyUnmeterable(surface), nil
|
||||
}
|
||||
route, outcome := m.matchVertex(reqPath, model, in.UserGroups)
|
||||
switch outcome {
|
||||
case matchOutcomeFound:
|
||||
return m.allowWithRoute(route, in.UserGroups), nil
|
||||
case matchOutcomeUnauthorised:
|
||||
return denyNoAuthorisedRoute(model), nil
|
||||
default:
|
||||
return denyUnknownModel(model), nil
|
||||
}
|
||||
return m.decide(route, outcome, surface, model, in.UserGroups, nil), nil
|
||||
}
|
||||
|
||||
// Bedrock likewise carries the model in the URL path (/model/{id}/{action}),
|
||||
@@ -167,52 +168,120 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar
|
||||
// before the model lookup; when the prefix is present, strip it from the
|
||||
// forwarded path so the real Bedrock endpoint receives its native path.
|
||||
if isBedrockPath(reqPath) {
|
||||
model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
||||
native, hadPrefix := splitBedrockNamespace(reqPath)
|
||||
route, outcome := m.matchBedrock(native, model, in.UserGroups)
|
||||
switch outcome {
|
||||
case matchOutcomeFound:
|
||||
out := m.allowWithRoute(route, in.UserGroups)
|
||||
if hadPrefix && out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
|
||||
out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix
|
||||
return m.decide(route, outcome, surface, model, in.UserGroups, func(out *middleware.Output) {
|
||||
if hadPrefix {
|
||||
stripBedrockNamespace(out)
|
||||
}
|
||||
return out, nil
|
||||
case matchOutcomeUnauthorised:
|
||||
return denyNoAuthorisedRoute(model), nil
|
||||
default:
|
||||
return denyUnknownModel(model), nil
|
||||
}
|
||||
}), nil
|
||||
}
|
||||
|
||||
model, ok := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
||||
if !ok || model == "" {
|
||||
// Non-inference endpoints (model listing) carry no model but still
|
||||
// need rewriting from the synth placeholder to a real upstream;
|
||||
// clients such as Codex call GET /v1/models at startup to enumerate
|
||||
// availability and read a 403 as "model unavailable".
|
||||
route, outcome := m.matchModelless(requestPath(in.URL), in.UserGroups)
|
||||
switch outcome {
|
||||
case matchOutcomeFound:
|
||||
return m.allowWithRoute(route, in.UserGroups), nil
|
||||
case matchOutcomeUnauthorised:
|
||||
// A recognised model-less endpoint exists but no provider
|
||||
// authorises the caller — deny as an authorisation failure
|
||||
// rather than masking it as a missing model.
|
||||
return denyNoAuthorisedRoute(model), nil
|
||||
default:
|
||||
return denyMissingModel(), nil
|
||||
}
|
||||
// GET /v1/models/{id} carries no body, so no model reaches the router in
|
||||
// metadata — but the path names one, and answering it confirms a model
|
||||
// exists and is reachable. Authorise it against the model table like any
|
||||
// other per-model request, then mark it non-inference so it still skips
|
||||
// the token pre-flight it would otherwise charge nothing against.
|
||||
if detail, isDetail := modelDetailID(reqPath); isDetail && isNonInferenceMethod(in.Method) {
|
||||
route, outcome := m.matchRoute(detail, surface, reqPath, in.UserGroups)
|
||||
return m.decide(route, outcome, surface, detail, in.UserGroups, func(out *middleware.Output) {
|
||||
markNonInference(out)
|
||||
// The parser reads models from JSON bodies only, and this request
|
||||
// has none, so stamp the one the path names. Without it the
|
||||
// guardrail's own allowlist — a separate, possibly narrower list
|
||||
// than the route's — never sees a model to check.
|
||||
out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMModel, Value: detail})
|
||||
}), nil
|
||||
}
|
||||
|
||||
vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
|
||||
route, outcome := m.matchRoute(model, vendor, requestPath(in.URL), in.UserGroups)
|
||||
if model == "" {
|
||||
return m.routeModelless(reqPath, surface, in.Method, in.UserGroups), nil
|
||||
}
|
||||
|
||||
route, outcome := m.matchRoute(model, surface, reqPath, in.UserGroups)
|
||||
return m.decide(route, outcome, surface, model, in.UserGroups, nil), nil
|
||||
}
|
||||
|
||||
// decide turns a per-model match result into the middleware's decision. Every
|
||||
// surface that routes by model shares the same two denial arms — a model no
|
||||
// route claims is not routable, one that some route claims but none authorises
|
||||
// for this caller is an authorisation failure — so they live here once.
|
||||
// decorate, when non-nil, adjusts the allow with whatever that surface needs.
|
||||
func (m *Middleware) decide(
|
||||
route ProviderRoute,
|
||||
outcome matchOutcome,
|
||||
surface, model string,
|
||||
userGroups []string,
|
||||
decorate func(*middleware.Output),
|
||||
) *middleware.Output {
|
||||
switch outcome {
|
||||
case matchOutcomeFound:
|
||||
return m.allowWithRoute(route, in.UserGroups), nil
|
||||
out := m.allowWithRoute(route, surface, userGroups)
|
||||
if decorate != nil {
|
||||
decorate(out)
|
||||
}
|
||||
return out
|
||||
case matchOutcomeUnauthorised:
|
||||
return denyNoAuthorisedRoute(model), nil
|
||||
return denyNoAuthorisedRoute(surface, model)
|
||||
default:
|
||||
return denyUnknownModel(model), nil
|
||||
return denyUnknownModel(surface, model)
|
||||
}
|
||||
}
|
||||
|
||||
// routeModelless serves the endpoints that name no model at all: the model
|
||||
// listing, the connection-warming probe, and the Bedrock inference-profile
|
||||
// lookup. They still need rewriting from the synth placeholder to a real
|
||||
// upstream — clients such as Codex call GET /v1/models at startup to enumerate
|
||||
// availability and read a 403 as "model unavailable".
|
||||
func (m *Middleware) routeModelless(reqPath, surface, method string, userGroups []string) *middleware.Output {
|
||||
route, outcome := m.matchModelless(reqPath, method, userGroups)
|
||||
switch outcome {
|
||||
case matchOutcomeFound:
|
||||
out := m.allowWithRoute(route, surface, userGroups)
|
||||
markNonInference(out)
|
||||
if _, hadPrefix := splitBedrockNamespace(reqPath); hadPrefix {
|
||||
stripBedrockNamespace(out)
|
||||
}
|
||||
// A route that enumerates its models bounds what the caller may use,
|
||||
// so the picker must not offer the rest: every entry outside the list
|
||||
// is a request the chain will deny.
|
||||
if reqPath == modelListingPath && len(route.Models) > 0 &&
|
||||
out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
|
||||
out.Mutations.RewriteUpstream.DiscoveryModels = append([]string(nil), route.Models...)
|
||||
}
|
||||
return out
|
||||
case matchOutcomeUnauthorised:
|
||||
// A recognised model-less endpoint exists but no provider authorises
|
||||
// the caller — deny as an authorisation failure rather than masking it
|
||||
// as a missing model.
|
||||
return denyNoAuthorisedRoute(surface, "")
|
||||
default:
|
||||
return denyMissingModel(surface)
|
||||
}
|
||||
}
|
||||
|
||||
// isNonInferenceMethod reports whether a request method is one the
|
||||
// non-inference endpoints actually use: the listing and the per-model lookup
|
||||
// are GET, the connection-warming probe is HEAD or GET. The method is the only
|
||||
// thing separating "GET /v1/models/{id}" from a POST to the same path carrying
|
||||
// an inference body, and the non-inference mark exempts a request from the
|
||||
// token pre-flight — so anything else falls through to normal per-model
|
||||
// routing, which denies when the request names no model.
|
||||
func isNonInferenceMethod(method string) bool {
|
||||
return method == http.MethodGet || method == http.MethodHead
|
||||
}
|
||||
|
||||
// markNonInference tags an allow as a request that spends no tokens, so the
|
||||
// limit check skips the management pre-flight it would charge nothing against.
|
||||
func markNonInference(out *middleware.Output) {
|
||||
out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"})
|
||||
}
|
||||
|
||||
// stripBedrockNamespace tells the rewrite to drop the optional "/bedrock"
|
||||
// gateway namespace so the upstream receives its native Bedrock path.
|
||||
func stripBedrockNamespace(out *middleware.Output) {
|
||||
if out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
|
||||
out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,12 +369,60 @@ func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []stri
|
||||
return best, matchOutcomeFound
|
||||
}
|
||||
|
||||
// isModelLessPath reports whether reqPath is a known OpenAI-shaped
|
||||
// non-inference endpoint that legitimately carries no model in its
|
||||
// request (the model-listing endpoints). These must route to an upstream
|
||||
// rather than deny, so model enumeration works end to end.
|
||||
// connectionWarmPath is the probe Anthropic clients send before their first
|
||||
// inference request to open the upstream connection early. Forwarding it
|
||||
// warms the connection the request will actually use; denying it only fills
|
||||
// the access log with rejections at every session start.
|
||||
const connectionWarmPath = "/api/hello"
|
||||
|
||||
// modelListingPath is the endpoint clients read at startup to populate
|
||||
// their model picker. Its response is a list the proxy can bound; the
|
||||
// per-model "/v1/models/{id}" lookup returns a single object and is left
|
||||
// alone.
|
||||
const modelListingPath = "/v1/models"
|
||||
|
||||
// isModelLessPath reports whether reqPath is a known non-inference endpoint
|
||||
// that legitimately carries no model at all: the model listing and the
|
||||
// connection-warming probe. These must route to an upstream rather than
|
||||
// deny, so model enumeration works end to end. The per-model
|
||||
// "/v1/models/{id}" lookup is deliberately excluded — it names a model, so
|
||||
// it is authorised against the model table instead (see modelDetailID).
|
||||
func isModelLessPath(reqPath string) bool {
|
||||
return reqPath == "/v1/models" || strings.HasPrefix(reqPath, "/v1/models/")
|
||||
return reqPath == modelListingPath || reqPath == connectionWarmPath
|
||||
}
|
||||
|
||||
// modelDetailID returns the model id named by a "/v1/models/{id}" lookup.
|
||||
// reqPath comes from url.URL.Path, which is already percent-decoded, so an
|
||||
// id carrying a "/" (a self-hosted "Qwen/Qwen2.5-0.5B-Instruct" sent as
|
||||
// "Qwen%2FQwen2.5-...") arrives whole and everything after the prefix is the
|
||||
// id, separators included.
|
||||
func modelDetailID(reqPath string) (string, bool) {
|
||||
if !strings.HasPrefix(reqPath, modelListingPath+"/") {
|
||||
return "", false
|
||||
}
|
||||
id := strings.TrimPrefix(reqPath, modelListingPath+"/")
|
||||
if id == "" {
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// isBedrockModelLessPath reports whether reqPath is a Bedrock
|
||||
// inference-profile lookup, optionally behind the "/bedrock" gateway
|
||||
// namespace. Clients read these at startup to resolve a configured profile
|
||||
// to its underlying model. They carry no model of their own, so they route
|
||||
// by path to a Bedrock provider rather than through the model table.
|
||||
//
|
||||
// On native AWS these live on the control plane ("bedrock.<region>") while a
|
||||
// provider's upstream is normally the runtime host ("bedrock-runtime.<region>"),
|
||||
// so forwarding yields a 404 there. That is deliberate: a client has one base
|
||||
// URL, so pointing it straight at the runtime host 404s identically, and
|
||||
// forwarding keeps the proxy transparent instead of inventing a policy denial
|
||||
// the client would never otherwise see. Operators whose Bedrock upstream is a
|
||||
// gateway that does serve the lookup get a working answer.
|
||||
func isBedrockModelLessPath(reqPath string) bool {
|
||||
native, _ := splitBedrockNamespace(reqPath)
|
||||
return native == "/inference-profiles" || strings.HasPrefix(native, "/inference-profiles/")
|
||||
}
|
||||
|
||||
// isVertexPath reports whether reqPath is a Google Vertex AI publisher
|
||||
@@ -332,20 +449,33 @@ func splitBedrockNamespace(reqPath string) (string, bool) {
|
||||
return reqPath, false
|
||||
}
|
||||
|
||||
// bedrockActions are the runtime actions that follow the model id in a
|
||||
// Bedrock path. count-tokens is here so a client can price its context
|
||||
// against the dedicated endpoint; denying it pushes that work back onto
|
||||
// the inference endpoint, which bills for it.
|
||||
var bedrockActions = []string{
|
||||
"/invoke",
|
||||
"/invoke-with-response-stream",
|
||||
"/converse",
|
||||
"/converse-stream",
|
||||
"/count-tokens",
|
||||
}
|
||||
|
||||
// isBedrockPath reports whether reqPath is an AWS Bedrock runtime model
|
||||
// endpoint: /model/{modelId}/{action} where action is invoke,
|
||||
// invoke-with-response-stream, converse, or converse-stream — optionally behind
|
||||
// a "/bedrock" gateway-namespace prefix. The model lives in the path, so these
|
||||
// requests are routed by path to the Bedrock provider.
|
||||
// endpoint: /model/{modelId}/{action} — optionally behind a "/bedrock"
|
||||
// gateway-namespace prefix. The model lives in the path, so these requests
|
||||
// are routed by path to the Bedrock provider.
|
||||
func isBedrockPath(reqPath string) bool {
|
||||
native, _ := splitBedrockNamespace(reqPath)
|
||||
if !strings.HasPrefix(native, "/model/") {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(native, "/invoke") ||
|
||||
strings.HasSuffix(native, "/invoke-with-response-stream") ||
|
||||
strings.HasSuffix(native, "/converse") ||
|
||||
strings.HasSuffix(native, "/converse-stream")
|
||||
for _, action := range bedrockActions {
|
||||
if strings.HasSuffix(native, action) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchVertex selects the Vertex provider authorised for the caller's groups
|
||||
@@ -425,19 +555,26 @@ func (m *Middleware) matchPathRoute(reqPath, model string, userGroups []string,
|
||||
// declaration order), matchOutcomeUnauthorised when no provider authorises
|
||||
// the caller, or matchOutcomeUnknownModel when the path isn't a recognised
|
||||
// model-less endpoint.
|
||||
func (m *Middleware) matchModelless(reqPath string, userGroups []string) (ProviderRoute, matchOutcome) {
|
||||
if !isModelLessPath(reqPath) {
|
||||
func (m *Middleware) matchModelless(reqPath, method string, userGroups []string) (ProviderRoute, matchOutcome) {
|
||||
if !isNonInferenceMethod(method) {
|
||||
return ProviderRoute{}, matchOutcomeUnknownModel
|
||||
}
|
||||
var candidates []ProviderRoute
|
||||
for _, route := range m.cfg.Providers {
|
||||
var eligible func(ProviderRoute) bool
|
||||
switch {
|
||||
case isBedrockModelLessPath(reqPath):
|
||||
eligible = func(r ProviderRoute) bool { return r.Bedrock }
|
||||
case isModelLessPath(reqPath):
|
||||
// Vertex/Bedrock are path-routed and don't serve OpenAI-style
|
||||
// model-listing endpoints; including them here could rewrite a
|
||||
// GET /v1/models to an upstream that 404s it.
|
||||
if route.Vertex || route.Bedrock {
|
||||
continue
|
||||
}
|
||||
if routeAuthorisesGroups(route, userGroups) {
|
||||
eligible = func(r ProviderRoute) bool { return !r.Vertex && !r.Bedrock }
|
||||
default:
|
||||
return ProviderRoute{}, matchOutcomeUnknownModel
|
||||
}
|
||||
|
||||
var candidates []ProviderRoute
|
||||
for _, route := range m.cfg.Providers {
|
||||
if eligible(route) && routeAuthorisesGroups(route, userGroups) {
|
||||
candidates = append(candidates, route)
|
||||
}
|
||||
}
|
||||
@@ -564,6 +701,16 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
|
||||
if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model {
|
||||
return true
|
||||
}
|
||||
// A client may pin a dated Anthropic id ("claude-sonnet-4-5-20250929")
|
||||
// where the operator registered the undated one. Only an undated
|
||||
// registration absorbs a dated request: normalising both sides would
|
||||
// let a route pinned to one dated release claim a different one, so an
|
||||
// operator who deliberately pinned a build would silently serve
|
||||
// another — and with several such routes, ordering would decide which.
|
||||
if candidate == llm.NormalizeAnthropicModel(candidate) &&
|
||||
candidate == llm.NormalizeAnthropicModel(model) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -612,7 +759,7 @@ func requestPath(raw string) string {
|
||||
// provider id so identity-stamping middlewares (llm_identity_inject)
|
||||
// tag the request with ONLY the groups that authorised this specific
|
||||
// route — not every group the peer happens to be in.
|
||||
func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *middleware.Output {
|
||||
func (m *Middleware) allowWithRoute(route ProviderRoute, surface string, userGroups []string) *middleware.Output {
|
||||
rewrite := &middleware.UpstreamRewrite{
|
||||
Scheme: route.UpstreamScheme,
|
||||
Host: route.UpstreamHost,
|
||||
@@ -634,7 +781,7 @@ func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *m
|
||||
// request time (cached + auto-refreshed) instead of a static value.
|
||||
bearer, err := m.gcpBearer(route.GCPServiceAccountKeyB64)
|
||||
if err != nil {
|
||||
return denyUpstreamAuth()
|
||||
return denyUpstreamAuth(surface)
|
||||
}
|
||||
authValue = bearer
|
||||
}
|
||||
@@ -704,11 +851,12 @@ func (m *Middleware) gcpTokenSource(saKeyB64 string) (oauth2.TokenSource, error)
|
||||
// denyUpstreamAuth is returned when the router cannot obtain the upstream
|
||||
// credential (e.g. a malformed service-account key or an unreachable token
|
||||
// endpoint). It surfaces as a 502 — an upstream problem, not a policy denial.
|
||||
func denyUpstreamAuth() *middleware.Output {
|
||||
func denyUpstreamAuth(surface string) *middleware.Output {
|
||||
return &middleware.Output{
|
||||
Decision: middleware.DecisionDeny,
|
||||
DenyStatus: 502,
|
||||
DenyReason: &middleware.DenyReason{
|
||||
Surface: surface,
|
||||
Code: denyCodeUpstreamAuth,
|
||||
Message: "could not obtain upstream credential",
|
||||
},
|
||||
@@ -722,11 +870,12 @@ func denyUpstreamAuth() *middleware.Output {
|
||||
// denyUnmeterable returns the deny envelope for a path-routed request whose
|
||||
// publisher has no parser surface, so its usage can't be metered. Serving it
|
||||
// would bypass token/budget caps, so it is rejected with a 403.
|
||||
func denyUnmeterable() *middleware.Output {
|
||||
func denyUnmeterable(surface string) *middleware.Output {
|
||||
return &middleware.Output{
|
||||
Decision: middleware.DecisionDeny,
|
||||
DenyStatus: 403,
|
||||
DenyReason: &middleware.DenyReason{
|
||||
Surface: surface,
|
||||
Code: denyCodeUnmeterable,
|
||||
Message: "request publisher is not supported for metering",
|
||||
},
|
||||
@@ -739,11 +888,12 @@ func denyUnmeterable() *middleware.Output {
|
||||
|
||||
// denyMissingModel returns the deny envelope for a request whose
|
||||
// envelope has no llm.model metadata.
|
||||
func denyMissingModel() *middleware.Output {
|
||||
func denyMissingModel(surface string) *middleware.Output {
|
||||
return &middleware.Output{
|
||||
Decision: middleware.DecisionDeny,
|
||||
DenyStatus: 403,
|
||||
DenyReason: &middleware.DenyReason{
|
||||
Surface: surface,
|
||||
Code: denyCodeNotRoutable,
|
||||
Message: "missing llm.model on request envelope",
|
||||
},
|
||||
@@ -756,11 +906,12 @@ func denyMissingModel() *middleware.Output {
|
||||
|
||||
// denyUnknownModel returns the deny envelope for a model that no
|
||||
// configured provider claims.
|
||||
func denyUnknownModel(model string) *middleware.Output {
|
||||
func denyUnknownModel(surface, model string) *middleware.Output {
|
||||
return &middleware.Output{
|
||||
Decision: middleware.DecisionDeny,
|
||||
DenyStatus: 403,
|
||||
DenyReason: &middleware.DenyReason{
|
||||
Surface: surface,
|
||||
Code: denyCodeNotRoutable,
|
||||
Message: fmt.Sprintf("no provider configured for model %s", model),
|
||||
Details: map[string]string{"model": model},
|
||||
@@ -775,11 +926,12 @@ func denyUnknownModel(model string) *middleware.Output {
|
||||
// denyNoAuthorisedRoute returns the deny envelope for a model that one
|
||||
// or more providers claim, but where no policy authorises the caller's
|
||||
// groups for any of those providers.
|
||||
func denyNoAuthorisedRoute(model string) *middleware.Output {
|
||||
func denyNoAuthorisedRoute(surface, model string) *middleware.Output {
|
||||
return &middleware.Output{
|
||||
Decision: middleware.DecisionDeny,
|
||||
DenyStatus: 403,
|
||||
DenyReason: &middleware.DenyReason{
|
||||
Surface: surface,
|
||||
Code: denyCodeNoAuthorisedRoute,
|
||||
Message: fmt.Sprintf("no policy authorises model %s for the caller's groups", model),
|
||||
Details: map[string]string{"model": model},
|
||||
|
||||
@@ -2,6 +2,7 @@ package llm_router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -60,6 +61,8 @@ func TestMiddlewareIdentity(t *testing.T) {
|
||||
[]string{
|
||||
middleware.KeyLLMResolvedProviderID,
|
||||
middleware.KeyLLMAuthorisingGroups,
|
||||
middleware.KeyLLMNonInference,
|
||||
middleware.KeyLLMModel,
|
||||
middleware.KeyLLMPolicyDecision,
|
||||
middleware.KeyLLMPolicyReason,
|
||||
},
|
||||
@@ -171,8 +174,12 @@ func TestRouter_MissingModel(t *testing.T) {
|
||||
// from which a model could be parsed). UserGroups matches defaultTestGroup.
|
||||
func newModellessInput(reqURL string) *middleware.Input {
|
||||
return &middleware.Input{
|
||||
Slot: middleware.SlotOnRequest,
|
||||
URL: reqURL,
|
||||
Slot: middleware.SlotOnRequest,
|
||||
URL: reqURL,
|
||||
// The non-inference endpoints are read requests; the method is what
|
||||
// separates them from an inference body posted to the same path, so
|
||||
// state it rather than leaning on the zero value.
|
||||
Method: http.MethodGet,
|
||||
UserGroups: []string{defaultTestGroup},
|
||||
}
|
||||
}
|
||||
@@ -197,6 +204,12 @@ func TestRouter_ModelLessPath_RoutesToAuthorisedProvider(t *testing.T) {
|
||||
|
||||
provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
||||
assert.Equal(t, "openai-prod", provider, "resolved provider must be the authorised route")
|
||||
|
||||
// The limits gate reads this to tell "no model applies here" from
|
||||
// "the model could not be determined", which fails closed.
|
||||
nonInference, ok := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
||||
require.True(t, ok, "model-less allow must mark the request non-inference")
|
||||
assert.Equal(t, "true", nonInference)
|
||||
}
|
||||
|
||||
func TestRouter_ModelLessPath_MultiProviderDeclarationOrder(t *testing.T) {
|
||||
@@ -873,3 +886,262 @@ func TestRouter_EmptyModelsClaimsAnyModel(t *testing.T) {
|
||||
resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
||||
assert.Equal(t, "litellm", resolved)
|
||||
}
|
||||
|
||||
// TestRouter_DatedAnthropicModelRoutes covers a client pinning a release
|
||||
// date on a model the operator registered undated. Exact matches still win,
|
||||
// so an operator who registers both dated releases keeps them distinct.
|
||||
func TestRouter_DatedAnthropicModelRoutes(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{{
|
||||
ID: "anthropic-prod",
|
||||
Vendor: "anthropic",
|
||||
Models: []string{"claude-sonnet-4-5"},
|
||||
AllowedGroupIDs: []string{defaultTestGroup},
|
||||
UpstreamScheme: "https",
|
||||
UpstreamHost: "api.anthropic.com",
|
||||
}}})
|
||||
|
||||
in := newInputWithModelAndURL("claude-sonnet-4-5-20250929", "/v1/messages")
|
||||
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
|
||||
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "a dated id must route to the undated registration")
|
||||
require.NotNil(t, out.Mutations)
|
||||
require.NotNil(t, out.Mutations.RewriteUpstream)
|
||||
assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host)
|
||||
}
|
||||
|
||||
// TestRouter_ConnectionWarmProbeRoutes covers the HEAD /api/hello probe an
|
||||
// Anthropic client sends before its first request. Forwarding it warms the
|
||||
// connection that request will use; denying it only wrote a rejection into
|
||||
// the access log at every session start.
|
||||
func TestRouter_ConnectionWarmProbeRoutes(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{{
|
||||
ID: "anthropic-prod",
|
||||
Vendor: "anthropic",
|
||||
Models: []string{"claude-sonnet-5"},
|
||||
AllowedGroupIDs: []string{defaultTestGroup},
|
||||
UpstreamScheme: "https",
|
||||
UpstreamHost: "api.anthropic.com",
|
||||
}}})
|
||||
|
||||
in := newModellessInput("/api/hello")
|
||||
in.Method = http.MethodHead
|
||||
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "the warm-up probe must reach the upstream")
|
||||
require.NotNil(t, out.Mutations)
|
||||
require.NotNil(t, out.Mutations.RewriteUpstream)
|
||||
assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host)
|
||||
|
||||
nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
||||
assert.Equal(t, "true", nonInference, "the probe carries no model to gate on")
|
||||
}
|
||||
|
||||
// TestRouter_ModelListingCarriesAuthorisedModels pins the list the proxy
|
||||
// bounds the discovery response with. A catch-all route enumerates nothing,
|
||||
// so it must not bound the upstream's list at all.
|
||||
func TestRouter_ModelListingCarriesAuthorisedModels(t *testing.T) {
|
||||
enumerated := ProviderRoute{
|
||||
ID: "anthropic-prod",
|
||||
Models: []string{"claude-sonnet-5", "claude-haiku-4-5"},
|
||||
AllowedGroupIDs: []string{defaultTestGroup},
|
||||
UpstreamScheme: "https",
|
||||
UpstreamHost: "api.anthropic.com",
|
||||
}
|
||||
|
||||
t.Run("enumerated route bounds the listing", func(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
||||
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.Mutations)
|
||||
require.NotNil(t, out.Mutations.RewriteUpstream)
|
||||
assert.Equal(t, []string{"claude-sonnet-5", "claude-haiku-4-5"},
|
||||
out.Mutations.RewriteUpstream.DiscoveryModels,
|
||||
"the picker must be bounded by what the route authorises")
|
||||
})
|
||||
|
||||
t.Run("catch-all route leaves the listing alone", func(t *testing.T) {
|
||||
catchAll := enumerated
|
||||
catchAll.Models = nil
|
||||
mw := New(Config{Providers: []ProviderRoute{catchAll}})
|
||||
|
||||
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.Mutations.RewriteUpstream)
|
||||
assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels,
|
||||
"a route that claims every model cannot bound the upstream's list")
|
||||
})
|
||||
|
||||
t.Run("per-model lookup is not a listing", func(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
||||
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, out.Mutations.RewriteUpstream)
|
||||
assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels,
|
||||
"the single-object lookup has no data array to filter")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRouter_ModelDetailHonoursAllowlist pins that GET /v1/models/{id} is
|
||||
// authorised against the model table. It carries no body model, so treating
|
||||
// it as a model-less endpoint would let a caller confirm a model the route
|
||||
// does not list — the listing itself is bounded to the allowlist, so the
|
||||
// detail lookup must be too.
|
||||
func TestRouter_ModelDetailHonoursAllowlist(t *testing.T) {
|
||||
enumerated := ProviderRoute{
|
||||
ID: "anthropic-prod",
|
||||
Models: []string{"claude-sonnet-5"},
|
||||
AllowedGroupIDs: []string{defaultTestGroup},
|
||||
UpstreamScheme: "https",
|
||||
UpstreamHost: "api.anthropic.com",
|
||||
}
|
||||
|
||||
t.Run("allowlisted model routes and skips metering", func(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
||||
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision)
|
||||
require.NotNil(t, out.Mutations)
|
||||
require.NotNil(t, out.Mutations.RewriteUpstream)
|
||||
assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host)
|
||||
|
||||
nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
||||
assert.Equal(t, "true", nonInference, "a detail lookup spends no tokens")
|
||||
})
|
||||
|
||||
t.Run("model outside the allowlist denies", func(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
||||
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-opus-5"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
||||
"a model no route lists must not be confirmed by the detail lookup")
|
||||
})
|
||||
|
||||
t.Run("dated id matches its undated registration", func(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
||||
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5-20250929"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
||||
"a pinned release of an allowlisted family stays reachable")
|
||||
})
|
||||
|
||||
t.Run("catch-all route still answers every lookup", func(t *testing.T) {
|
||||
catchAll := enumerated
|
||||
catchAll.Models = nil
|
||||
mw := New(Config{Providers: []ProviderRoute{catchAll}})
|
||||
|
||||
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/anything-at-all"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
||||
"a gateway that enumerates nothing cannot refuse a lookup")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRouter_NonInferenceRequiresReadMethod pins that the non-inference mark —
|
||||
// which exempts a request from the token pre-flight — is reachable only by the
|
||||
// read methods these endpoints actually use. A POST to the same path could
|
||||
// carry an inference body, so it must not buy the exemption; it falls through
|
||||
// to normal per-model routing instead, which denies when no model is named.
|
||||
func TestRouter_NonInferenceRequiresReadMethod(t *testing.T) {
|
||||
route := ProviderRoute{
|
||||
ID: "gateway",
|
||||
AllowedGroupIDs: []string{defaultTestGroup},
|
||||
UpstreamScheme: "https",
|
||||
UpstreamHost: "gateway.example.com",
|
||||
}
|
||||
|
||||
for _, path := range []string{"/v1/models", "/v1/models/claude-sonnet-5", "/api/hello"} {
|
||||
t.Run("POST "+path, func(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{route}})
|
||||
|
||||
in := newModellessInput(path)
|
||||
in.Method = http.MethodPost
|
||||
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
||||
"a write to a non-inference path must not route unmetered")
|
||||
|
||||
nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
||||
assert.NotEqual(t, "true", nonInference,
|
||||
"only a read method may skip the token pre-flight")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("HEAD keeps the warm probe working", func(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{route}})
|
||||
|
||||
in := newModellessInput(connectionWarmPath)
|
||||
in.Method = http.MethodHead
|
||||
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
||||
"the HEAD warm probe must still reach the upstream")
|
||||
|
||||
nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
||||
assert.Equal(t, "true", nonInference,
|
||||
"the HEAD warm probe carries no model to meter")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRouter_PinnedDatedModelStaysDistinct pins that a route registered
|
||||
// against one dated Anthropic release does not claim another. Normalising
|
||||
// both sides of the comparison made every dated build of a family
|
||||
// interchangeable, so an operator who deliberately pinned a build would have
|
||||
// served a different one — and with several such routes, declaration or path
|
||||
// order would have decided which.
|
||||
func TestRouter_PinnedDatedModelStaysDistinct(t *testing.T) {
|
||||
pinned := ProviderRoute{
|
||||
ID: "anthropic-pinned",
|
||||
Vendor: "anthropic",
|
||||
Models: []string{"claude-sonnet-4-5-20250101"},
|
||||
AllowedGroupIDs: []string{defaultTestGroup},
|
||||
UpstreamScheme: "https",
|
||||
UpstreamHost: "pinned.example.com",
|
||||
}
|
||||
|
||||
t.Run("a different dated release is not claimed", func(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{pinned}})
|
||||
in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages")
|
||||
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
|
||||
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
||||
"a route pinned to one dated build must not serve another")
|
||||
})
|
||||
|
||||
t.Run("its own dated release still routes", func(t *testing.T) {
|
||||
mw := New(Config{Providers: []ProviderRoute{pinned}})
|
||||
in := newInputWithModelAndURL("claude-sonnet-4-5-20250101", "/v1/messages")
|
||||
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
|
||||
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, middleware.DecisionAllow, out.Decision, "the exact match must still route")
|
||||
})
|
||||
|
||||
t.Run("two pinned builds each route to their own provider", func(t *testing.T) {
|
||||
other := pinned
|
||||
other.ID = "anthropic-pinned-newer"
|
||||
other.Models = []string{"claude-sonnet-4-5-20250202"}
|
||||
other.UpstreamHost = "newer.example.com"
|
||||
mw := New(Config{Providers: []ProviderRoute{pinned, other}})
|
||||
|
||||
in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages")
|
||||
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
|
||||
|
||||
out, err := mw.Invoke(context.Background(), in)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, middleware.DecisionAllow, out.Decision)
|
||||
require.NotNil(t, out.Mutations)
|
||||
require.NotNil(t, out.Mutations.RewriteUpstream)
|
||||
assert.Equal(t, "newer.example.com", out.Mutations.RewriteUpstream.Host,
|
||||
"declaration order must not decide between two deliberately pinned builds")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,11 +11,78 @@ var codeRegex = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`)
|
||||
// denyResponse is the on-wire shape rendered by RenderDenyResponse.
|
||||
// Keeping this as a typed struct ensures we never leak
|
||||
// middleware-supplied bytes outside known fields.
|
||||
//
|
||||
// Type and Error mirror the denial in the vendor's own error shape when
|
||||
// the request reached a known LLM surface. LLM clients only parse their
|
||||
// provider's envelope, so without the mirror a budget stop reaches the
|
||||
// user as an unexplained API error. The NetBird fields stay where they
|
||||
// were, so the body is a superset and existing consumers are unaffected.
|
||||
type denyResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
Middleware string `json:"middleware,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Error *providerError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// providerError is the nested error object both vendor envelopes carry.
|
||||
type providerError struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// Vendor error types keyed by HTTP status, per each provider's published
|
||||
// error reference.
|
||||
const (
|
||||
anthropicErrInvalidRequest = "invalid_request_error"
|
||||
anthropicErrPermission = "permission_error"
|
||||
anthropicErrRateLimit = "rate_limit_error"
|
||||
anthropicErrAPI = "api_error"
|
||||
openAIErrInvalidRequest = "invalid_request_error"
|
||||
openAIErrRateLimit = "rate_limit_error"
|
||||
)
|
||||
|
||||
// providerEnvelope returns the vendor-shaped mirror for a denial on the
|
||||
// given surface, or nil when the surface has no envelope we can speak.
|
||||
// message is the already-redacted public message.
|
||||
func providerEnvelope(surface, code, message string, status int) (string, *providerError) {
|
||||
switch surface {
|
||||
case "anthropic":
|
||||
return "error", &providerError{
|
||||
Type: anthropicErrorType(status),
|
||||
Message: message,
|
||||
}
|
||||
case "openai":
|
||||
return "", &providerError{
|
||||
Type: openAIErrorType(status),
|
||||
Message: message,
|
||||
Code: code,
|
||||
}
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func anthropicErrorType(status int) string {
|
||||
switch status {
|
||||
case http.StatusForbidden:
|
||||
return anthropicErrPermission
|
||||
case http.StatusTooManyRequests:
|
||||
return anthropicErrRateLimit
|
||||
case http.StatusBadRequest:
|
||||
return anthropicErrInvalidRequest
|
||||
default:
|
||||
return anthropicErrAPI
|
||||
}
|
||||
}
|
||||
|
||||
func openAIErrorType(status int) string {
|
||||
if status == http.StatusTooManyRequests {
|
||||
return openAIErrRateLimit
|
||||
}
|
||||
return openAIErrInvalidRequest
|
||||
}
|
||||
|
||||
// RenderDenyResponse writes a structured JSON deny body. Status is
|
||||
@@ -36,6 +103,7 @@ func RenderDenyResponse(w http.ResponseWriter, middlewareID string, reason *Deny
|
||||
Message: truncate(Scan(reason.Message), 256),
|
||||
Middleware: truncate(Scan(middlewareID), 64),
|
||||
}
|
||||
resp.Type, resp.Error = providerEnvelope(reason.Surface, resp.Code, resp.Message, status)
|
||||
if n := len(reason.Details); n > 0 {
|
||||
resp.Details = make(map[string]string, min(n, 8))
|
||||
for k, v := range reason.Details {
|
||||
|
||||
92
proxy/internal/middleware/decision_test.go
Normal file
92
proxy/internal/middleware/decision_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
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"])
|
||||
}
|
||||
@@ -22,6 +22,15 @@ const (
|
||||
// body. Empty for clients that don't send one.
|
||||
KeyLLMSessionID = "llm.session_id"
|
||||
|
||||
// Sub-agent attribution (emitted by llm_request_parser from the
|
||||
// client's request headers). A coding agent that spawns helpers
|
||||
// stamps the spawned agent's id, and the spawning agent's id when
|
||||
// the helper is itself nested, so cost within one session can be
|
||||
// split across the agents that ran in parallel. These identify an
|
||||
// agent, not a person or a device: never treat them as a user id.
|
||||
KeyLLMAgentID = "llm.agent_id"
|
||||
KeyLLMParentAgentID = "llm.parent_agent_id"
|
||||
|
||||
// LLM response-side metadata (emitted by llm_response_parser).
|
||||
//nolint:gosec // metadata key name, not a credential
|
||||
KeyLLMInputTokens = "llm.input_tokens"
|
||||
@@ -66,6 +75,14 @@ const (
|
||||
// downstream gateways' spend logs.
|
||||
KeyLLMAuthorisingGroups = "llm.authorising_groups"
|
||||
|
||||
// LLM non-inference marker (emitted by llm_router on the allow path
|
||||
// for endpoints that legitimately carry no model, such as model
|
||||
// listing). The router still authorises these against the caller's
|
||||
// groups; the marker only tells the limits gate that a per-model
|
||||
// allowlist has nothing to evaluate, so an empty model must not be
|
||||
// read as an undetermined one. Never derived from client input.
|
||||
KeyLLMNonInference = "llm.non_inference"
|
||||
|
||||
// LLM policy attribution (emitted by llm_limit_check on the allow
|
||||
// path). Names the policy that paid for this request and the
|
||||
// dimension counters the post-flight llm_limit_record middleware
|
||||
|
||||
@@ -179,6 +179,12 @@ type DenyReason struct {
|
||||
Code string
|
||||
Message string
|
||||
Details map[string]string
|
||||
// Surface names the LLM API dialect the caller speaks (the
|
||||
// llm.provider value), so the rendered body can mirror the denial in
|
||||
// that vendor's error shape alongside the NetBird fields. Empty for
|
||||
// non-LLM middlewares and for denials raised before a surface was
|
||||
// resolved; the body then carries the NetBird fields alone.
|
||||
Surface string
|
||||
}
|
||||
|
||||
// Output is the value each middleware returns to the dispatcher. The
|
||||
@@ -247,6 +253,12 @@ type UpstreamRewrite struct {
|
||||
// without verifying its TLS certificate. Set by llm_router from the
|
||||
// provider's skip_tls_verification for self-hosted / internal gateways.
|
||||
SkipTLSVerify bool
|
||||
// DiscoveryModels, when non-empty, is the set of model ids the resolved
|
||||
// route authorises, and the proxy drops everything else from the
|
||||
// model-listing response. Empty leaves the upstream's list untouched,
|
||||
// which is what a route that claims every model wants. Set by
|
||||
// llm_router on a model-listing request only.
|
||||
DiscoveryModels []string
|
||||
}
|
||||
|
||||
// AuthHeader is a single name/value pair the proxy injects on the
|
||||
|
||||
191
proxy/internal/proxy/discovery_filter.go
Normal file
191
proxy/internal/proxy/discovery_filter.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
sharedllm "github.com/netbirdio/netbird/shared/llm"
|
||||
)
|
||||
|
||||
// maxDiscoveryBodyBytes bounds the model-listing response the filter will
|
||||
// buffer. A listing is a few kilobytes of ids; anything larger is not a
|
||||
// listing we recognise, and buffering it to rewrite would cost more than
|
||||
// the filtering is worth.
|
||||
const maxDiscoveryBodyBytes = 1 << 20
|
||||
|
||||
// modelDiscoveryFilter returns a ModifyResponse hook that drops models the
|
||||
// caller's policy does not authorise from a model-listing response, then
|
||||
// delegates to next (which may be nil).
|
||||
//
|
||||
// Clients populate their model picker from this endpoint, so an unfiltered
|
||||
// list offers models the very next request denies. The filter is
|
||||
// best-effort: a response it cannot safely rewrite passes through
|
||||
// untouched rather than reaching the client corrupted.
|
||||
func modelDiscoveryFilter(allowed []string, next func(*http.Response) error) func(*http.Response) error {
|
||||
permitted := make(map[string]struct{}, len(allowed)*2)
|
||||
for _, id := range allowed {
|
||||
permitted[id] = struct{}{}
|
||||
permitted[sharedllm.NormalizeAnthropicModel(id)] = struct{}{}
|
||||
}
|
||||
|
||||
return func(resp *http.Response) error {
|
||||
if err := filterModelListing(resp, permitted); err != nil {
|
||||
return err
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
return next(resp)
|
||||
}
|
||||
}
|
||||
|
||||
// filterModelListing rewrites the response body in place, keeping only the
|
||||
// entries whose id the policy authorises. Responses that are not a plain
|
||||
// JSON listing are left alone.
|
||||
func filterModelListing(resp *http.Response, permitted map[string]struct{}) error {
|
||||
if !isPlainJSONListing(resp) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// One byte past the cap, so an oversized body is detectable without
|
||||
// buffering all of it.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoveryBodyBytes+1))
|
||||
if err != nil {
|
||||
_ = resp.Body.Close()
|
||||
return err
|
||||
}
|
||||
if len(body) > maxDiscoveryBodyBytes {
|
||||
// Too large to filter. Put the bytes already read back in front of the
|
||||
// unread remainder and forward the response exactly as the upstream
|
||||
// sent it, headers included. Buffering what was read and closing here
|
||||
// would truncate the body at the cap and hand the client a short,
|
||||
// invalid listing — worse than not filtering at all.
|
||||
resp.Body = spliceBody(body, resp.Body)
|
||||
return nil
|
||||
}
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filtered, ok := filterListingBody(body, permitted)
|
||||
if !ok {
|
||||
restoreBody(resp, body)
|
||||
return nil
|
||||
}
|
||||
restoreBody(resp, filtered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPlainJSONListing reports whether the response is a JSON body the filter
|
||||
// can parse. A content-encoded body is skipped: the transport only
|
||||
// transparently decompresses what it negotiated itself, and the client
|
||||
// negotiates its own encoding on this request.
|
||||
func isPlainJSONListing(resp *http.Response) bool {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return false
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
if enc := resp.Header.Get("Content-Encoding"); enc != "" && !strings.EqualFold(enc, "identity") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/json")
|
||||
}
|
||||
|
||||
// filterListingBody returns the listing with unauthorised entries removed.
|
||||
// ok is false when the body is not a listing shape, in which case the
|
||||
// caller must forward the original bytes.
|
||||
func filterListingBody(body []byte, permitted map[string]struct{}) ([]byte, bool) {
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
raw, present := doc["data"]
|
||||
if !present {
|
||||
return nil, false
|
||||
}
|
||||
var entries []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &entries); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
kept := make([]map[string]json.RawMessage, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entryPermitted(entry, permitted) {
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(kept)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
doc["data"] = encoded
|
||||
out, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// entryPermitted reports whether a listing entry names a model the policy
|
||||
// authorises, trying every form the same model is written in.
|
||||
func entryPermitted(entry map[string]json.RawMessage, permitted map[string]struct{}) bool {
|
||||
raw, ok := entry["id"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var id string
|
||||
if err := json.Unmarshal(raw, &id); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range modelIDForms(id) {
|
||||
if _, ok := permitted[candidate]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// modelIDForms returns the forms a single model id may be written in: the id
|
||||
// itself, its undated form, and the same two with a gateway's provider
|
||||
// prefix removed ("vertex_ai/claude-sonnet-5"). The bare id is tried first,
|
||||
// because a self-hosted id can legitimately contain a slash of its own
|
||||
// ("Qwen/Qwen2.5-0.5B-Instruct") and must not be cut down to its tail.
|
||||
func modelIDForms(id string) []string {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
forms := []string{id, sharedllm.NormalizeAnthropicModel(id)}
|
||||
if slash := strings.LastIndex(id, "/"); slash >= 0 {
|
||||
tail := id[slash+1:]
|
||||
forms = append(forms, tail, sharedllm.NormalizeAnthropicModel(tail))
|
||||
}
|
||||
return forms
|
||||
}
|
||||
|
||||
// restoreBody puts body back on the response and fixes the length headers
|
||||
// so the client reads exactly what is there.
|
||||
// spliceBody returns a ReadCloser that yields prefix followed by whatever is
|
||||
// left in rest, closing rest when closed. It lets the filter put back bytes it
|
||||
// consumed while deciding, without owning the rest of the stream.
|
||||
func spliceBody(prefix []byte, rest io.ReadCloser) io.ReadCloser {
|
||||
return struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
Reader: io.MultiReader(bytes.NewReader(prefix), rest),
|
||||
Closer: rest,
|
||||
}
|
||||
}
|
||||
|
||||
func restoreBody(resp *http.Response, body []byte) {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.ContentLength = int64(len(body))
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
}
|
||||
217
proxy/internal/proxy/discovery_filter_test.go
Normal file
217
proxy/internal/proxy/discovery_filter_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// jsonListingResponse builds a 200 model-listing response with the given
|
||||
// body, as an upstream would return it.
|
||||
func jsonListingResponse(body string) *http.Response {
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
ContentLength: int64(len(body)),
|
||||
}
|
||||
resp.Header.Set("Content-Type", "application/json")
|
||||
return resp
|
||||
}
|
||||
|
||||
// listedIDs runs the filter and returns the ids left in the response.
|
||||
func listedIDs(t *testing.T, allowed []string, body string) []string {
|
||||
t.Helper()
|
||||
resp := jsonListingResponse(body) //nolint:bodyclose // in-memory body, replaced by the filter
|
||||
require.NoError(t, modelDiscoveryFilter(allowed, nil)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var doc struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(raw, &doc), "filtered body must stay valid JSON")
|
||||
|
||||
ids := make([]string, 0, len(doc.Data))
|
||||
for _, entry := range doc.Data {
|
||||
ids = append(ids, entry.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestModelDiscoveryFilter_KeepsOnlyAuthorisedModels covers the picker a
|
||||
// developer sees: an unfiltered upstream list offers every model the shared
|
||||
// key can reach, and each one the policy excludes is a request the chain
|
||||
// denies a moment later.
|
||||
func TestModelDiscoveryFilter_KeepsOnlyAuthorisedModels(t *testing.T) {
|
||||
ids := listedIDs(t, []string{"claude-sonnet-5", "claude-haiku-4-5"}, `{
|
||||
"data": [
|
||||
{"id": "claude-opus-5", "display_name": "Claude Opus 5"},
|
||||
{"id": "claude-sonnet-5", "display_name": "Claude Sonnet 5"},
|
||||
{"id": "claude-haiku-4-5"}
|
||||
],
|
||||
"has_more": false
|
||||
}`)
|
||||
|
||||
assert.Equal(t, []string{"claude-sonnet-5", "claude-haiku-4-5"}, ids,
|
||||
"only the models the route authorises may reach the picker")
|
||||
}
|
||||
|
||||
// TestModelDiscoveryFilter_MatchesDatedAndPrefixedIDs pins the two id forms
|
||||
// a gateway returns for a model the operator registered plainly.
|
||||
func TestModelDiscoveryFilter_MatchesDatedAndPrefixedIDs(t *testing.T) {
|
||||
ids := listedIDs(t, []string{"claude-sonnet-4-5", "anthropic.claude-opus-5"}, `{
|
||||
"data": [
|
||||
{"id": "claude-sonnet-4-5-20250929"},
|
||||
{"id": "bedrock/anthropic.claude-opus-5"},
|
||||
{"id": "gpt-4o"}
|
||||
]
|
||||
}`)
|
||||
|
||||
assert.Equal(t, []string{"claude-sonnet-4-5-20250929", "bedrock/anthropic.claude-opus-5"}, ids,
|
||||
"a dated or provider-prefixed id must match its registered form")
|
||||
}
|
||||
|
||||
// TestModelDiscoveryFilter_PreservesEnvelopeFields guards the rest of the
|
||||
// document: clients read paging fields alongside data.
|
||||
func TestModelDiscoveryFilter_PreservesEnvelopeFields(t *testing.T) {
|
||||
resp := jsonListingResponse(`{"data":[{"id":"claude-sonnet-5"}],"has_more":true,"first_id":"x"}`) //nolint:bodyclose // in-memory body, replaced by the filter
|
||||
require.NoError(t, modelDiscoveryFilter([]string{"claude-sonnet-5"}, nil)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var doc map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &doc))
|
||||
assert.Equal(t, true, doc["has_more"], "paging fields must survive the rewrite")
|
||||
assert.Equal(t, "x", doc["first_id"])
|
||||
assert.Equal(t, strconv.Itoa(len(raw)), resp.Header.Get("Content-Length"),
|
||||
"Content-Length must match the rewritten body")
|
||||
}
|
||||
|
||||
// TestModelDiscoveryFilter_PassesThroughUnfilterable covers the responses
|
||||
// the filter must not touch: a compressed body it cannot parse, a non-JSON
|
||||
// body, an error status, and a document with no data array.
|
||||
func TestModelDiscoveryFilter_PassesThroughUnfilterable(t *testing.T) {
|
||||
cases := map[string]func() *http.Response{
|
||||
"compressed": func() *http.Response {
|
||||
resp := jsonListingResponse(`{"data":[{"id":"gpt-4o"}]}`)
|
||||
resp.Header.Set("Content-Encoding", "gzip")
|
||||
return resp
|
||||
},
|
||||
"not json": func() *http.Response {
|
||||
resp := jsonListingResponse(`{"data":[{"id":"gpt-4o"}]}`)
|
||||
resp.Header.Set("Content-Type", "text/html")
|
||||
return resp
|
||||
},
|
||||
"error status": func() *http.Response {
|
||||
resp := jsonListingResponse(`{"data":[{"id":"gpt-4o"}]}`)
|
||||
resp.StatusCode = http.StatusInternalServerError
|
||||
return resp
|
||||
},
|
||||
"no data array": func() *http.Response {
|
||||
return jsonListingResponse(`{"object":"list"}`)
|
||||
},
|
||||
}
|
||||
|
||||
for name, build := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resp := build() //nolint:bodyclose // in-memory body, replaced by the filter
|
||||
original, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(original))
|
||||
|
||||
require.NoError(t, modelDiscoveryFilter([]string{"claude-sonnet-5"}, nil)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter
|
||||
|
||||
got, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(original), string(got), "an unfilterable response must reach the client unchanged")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelDiscoveryFilter_RunsNextHook pins that an existing
|
||||
// ModifyResponse hook still runs after filtering.
|
||||
func TestModelDiscoveryFilter_RunsNextHook(t *testing.T) {
|
||||
called := false
|
||||
next := func(*http.Response) error {
|
||||
called = true
|
||||
return nil
|
||||
}
|
||||
|
||||
resp := jsonListingResponse(`{"data":[{"id":"claude-sonnet-5"}]}`) //nolint:bodyclose // in-memory body, replaced by the filter
|
||||
require.NoError(t, modelDiscoveryFilter([]string{"claude-sonnet-5"}, next)(resp)) //nolint:bodyclose // in-memory body, replaced by the filter
|
||||
assert.True(t, called, "the chained hook must still run")
|
||||
}
|
||||
|
||||
// TestModelDiscoveryFilter_KeepsSlashBearingIDs covers self-hosted backends
|
||||
// whose model ids carry a slash of their own. Treating the slash as a
|
||||
// gateway prefix and keeping only the tail dropped every such model from
|
||||
// the picker even though the policy named it exactly.
|
||||
func TestModelDiscoveryFilter_KeepsSlashBearingIDs(t *testing.T) {
|
||||
ids := listedIDs(t, []string{"Qwen/Qwen2.5-0.5B-Instruct"}, `{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "Qwen/Qwen2.5-0.5B-Instruct"},
|
||||
{"id": "Qwen/Qwen2.5-7B-Instruct"}
|
||||
]
|
||||
}`)
|
||||
|
||||
assert.Equal(t, []string{"Qwen/Qwen2.5-0.5B-Instruct"}, ids,
|
||||
"a slash inside the model id is part of the id, not a provider prefix")
|
||||
}
|
||||
|
||||
// TestModelDiscoveryFilter_ForwardsOversizedBodyIntact covers a listing past
|
||||
// the buffering cap. The filter reads one byte beyond the cap to detect the
|
||||
// size; forwarding only what it read would hand the client a body truncated
|
||||
// at exactly 1 MiB — valid-looking, short, and unparseable as JSON. The bytes
|
||||
// already read must be spliced back in front of the unread remainder so the
|
||||
// response reaches the client exactly as the upstream sent it.
|
||||
func TestModelDiscoveryFilter_ForwardsOversizedBodyIntact(t *testing.T) {
|
||||
// A well-formed listing whose single entry pads the body past the cap.
|
||||
padding := strings.Repeat("x", maxDiscoveryBodyBytes)
|
||||
body := `{"object":"list","data":[{"id":"gpt-4o","note":"` + padding + `"}]}`
|
||||
require.Greater(t, len(body), maxDiscoveryBodyBytes+1,
|
||||
"the fixture must exceed the cap by more than the one-byte probe")
|
||||
|
||||
resp := jsonListingResponse(body) //nolint:bodyclose // in-memory body
|
||||
require.NoError(t, modelDiscoveryFilter(nil, nil)(resp)) //nolint:bodyclose // in-memory body
|
||||
|
||||
got, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(body), len(got),
|
||||
"an oversized listing must reach the client whole, not truncated at the cap")
|
||||
assert.Equal(t, body, string(got), "the forwarded bytes must be the upstream's own")
|
||||
|
||||
var doc map[string]json.RawMessage
|
||||
assert.NoError(t, json.Unmarshal(got, &doc),
|
||||
"the forwarded body must still parse as JSON")
|
||||
}
|
||||
|
||||
// TestModelDiscoveryFilter_OversizedBodyKeepsUpstreamHeaders pins that the
|
||||
// oversized path leaves the response metadata alone. Rewriting Content-Length
|
||||
// to the truncated prefix is what made the corruption invisible to the client
|
||||
// until it tried to parse.
|
||||
func TestModelDiscoveryFilter_OversizedBodyKeepsUpstreamHeaders(t *testing.T) {
|
||||
padding := strings.Repeat("x", maxDiscoveryBodyBytes)
|
||||
body := `{"object":"list","data":[{"id":"gpt-4o","note":"` + padding + `"}]}`
|
||||
|
||||
resp := jsonListingResponse(body) //nolint:bodyclose // in-memory body
|
||||
resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
require.NoError(t, modelDiscoveryFilter(nil, nil)(resp)) //nolint:bodyclose // in-memory body
|
||||
|
||||
assert.Equal(t, int64(len(body)), resp.ContentLength,
|
||||
"ContentLength must keep describing the body the client receives")
|
||||
assert.Equal(t, strconv.Itoa(len(body)), resp.Header.Get("Content-Length"),
|
||||
"the Content-Length header must not be rewritten to the truncated prefix")
|
||||
}
|
||||
@@ -363,6 +363,9 @@ func (p *ReverseProxy) forwardUpstream(respWriter http.ResponseWriter, r *http.R
|
||||
if result.rewriteRedirects {
|
||||
rp.ModifyResponse = p.rewriteLocationFunc(effectiveURL, rewriteMatchedPath, r) //nolint:bodyclose
|
||||
}
|
||||
if upstreamRewrite != nil && len(upstreamRewrite.DiscoveryModels) > 0 {
|
||||
rp.ModifyResponse = modelDiscoveryFilter(upstreamRewrite.DiscoveryModels, rp.ModifyResponse) //nolint:bodyclose // the hook replaces the body and closes the original
|
||||
}
|
||||
rp.ServeHTTP(respWriter, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,27 @@ func NormalizeBedrockModel(modelID string) string {
|
||||
return bedrockVersionSuffix.ReplaceAllString(m, "")
|
||||
}
|
||||
|
||||
// anthropicDatedModel matches a Claude model id carrying the trailing
|
||||
// "-YYYYMMDD" release-date suffix Anthropic appends to a pinned release,
|
||||
// capturing the id without it. The "claude" anchor is load-bearing: pricing
|
||||
// looks every model up through this helper regardless of surface, and an
|
||||
// operator may register a custom id with any shape at all, so an unanchored
|
||||
// "-\d{8}$" would let "internal-llm-20250101" silently inherit the rate
|
||||
// registered for "internal-llm". The anchor also covers the vendor-prefixed
|
||||
// forms ("anthropic.claude-...", "us.anthropic.claude-...").
|
||||
var anthropicDatedModel = regexp.MustCompile(`(?i)^(.*claude.*)-\d{8}$`)
|
||||
|
||||
// NormalizeAnthropicModel strips the trailing release-date suffix from a
|
||||
// Claude model id, e.g. "claude-sonnet-4-5-20250929" -> "claude-sonnet-4-5",
|
||||
// so a dated id a client pins matches the undated one the operator
|
||||
// registered. Ids that are not Claude-family are returned untouched.
|
||||
// Callers try the verbatim id first and fall back to this, so two dated
|
||||
// releases of the same family stay distinct wherever both are registered
|
||||
// explicitly.
|
||||
func NormalizeAnthropicModel(modelID string) string {
|
||||
return anthropicDatedModel.ReplaceAllString(modelID, "$1")
|
||||
}
|
||||
|
||||
// NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id
|
||||
// (e.g. "claude-sonnet-4-5@20250929" -> "claude-sonnet-4-5") so it matches
|
||||
// the catalog/pricing key. Vertex publisher models are priced under their
|
||||
|
||||
@@ -34,3 +34,29 @@ func TestNormalizeVertexModel(t *testing.T) {
|
||||
require.Equal(t, want, NormalizeVertexModel(in), "normalize %q", in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAnthropicModel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
|
||||
"claude-3-5-haiku-20241022": "claude-3-5-haiku",
|
||||
"claude-sonnet-5": "claude-sonnet-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
"anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5",
|
||||
"anthropic.claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5",
|
||||
"us.anthropic.claude-opus-4-8-20250101": "us.anthropic.claude-opus-4-8",
|
||||
// Non-Claude ids must survive untouched even when they end in eight
|
||||
// consecutive digits: an operator can register a custom model under
|
||||
// any id, and pricing looks every one of them up through this helper.
|
||||
"gpt-4o": "gpt-4o",
|
||||
"gpt-4o-2024-08-06": "gpt-4o-2024-08-06",
|
||||
"gpt-4o-20240806": "gpt-4o-20240806",
|
||||
"internal-llm-20250101": "internal-llm-20250101",
|
||||
"deepseek-r1-20250120": "deepseek-r1-20250120",
|
||||
"Qwen/Qwen2.5-20250101": "Qwen/Qwen2.5-20250101",
|
||||
"gemini-2-5-pro-20250101": "gemini-2-5-pro-20250101",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
require.Equal(t, want, NormalizeAnthropicModel(in), "normalize %q", in)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4608,7 +4608,7 @@ components:
|
||||
|
||||
FleetDMMatchAttributes:
|
||||
type: object
|
||||
description: Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly
|
||||
description: Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly
|
||||
additionalProperties: false
|
||||
properties:
|
||||
disk_encryption_enabled:
|
||||
|
||||
@@ -2876,7 +2876,7 @@ type EDRFleetDMRequest struct {
|
||||
// LastSyncedInterval The devices last sync requirement interval in hours. Minimum value is 24 hours
|
||||
LastSyncedInterval int `json:"last_synced_interval"`
|
||||
|
||||
// MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly
|
||||
// MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly
|
||||
MatchAttributes FleetDMMatchAttributes `json:"match_attributes"`
|
||||
}
|
||||
|
||||
@@ -2909,7 +2909,7 @@ type EDRFleetDMResponse struct {
|
||||
// LastSyncedInterval The devices last sync requirement interval in hours.
|
||||
LastSyncedInterval int `json:"last_synced_interval"`
|
||||
|
||||
// MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly
|
||||
// MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly
|
||||
MatchAttributes FleetDMMatchAttributes `json:"match_attributes"`
|
||||
|
||||
// UpdatedAt Timestamp of when the integration was last updated.
|
||||
@@ -3129,7 +3129,7 @@ type Event struct {
|
||||
// EventActivityCode The string code of the activity that occurred during the event
|
||||
type EventActivityCode string
|
||||
|
||||
// FleetDMMatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly
|
||||
// FleetDMMatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open-source version. Premium-only attributes are marked accordingly
|
||||
type FleetDMMatchAttributes struct {
|
||||
// DiskEncryptionEnabled Whether disk encryption (FileVault/BitLocker) must be enabled on the host
|
||||
DiskEncryptionEnabled *bool `json:"disk_encryption_enabled,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user