Compare commits

..

53 Commits

Author SHA1 Message Date
mlsmaycon
ffc20a0624 [debug] Decompose a Bedrock listing in the live e2e suite
Runs beside the other live discovery tests and takes its credential from
the same AWS_BEARER_TOKEN_BEDROCK the suite already sources, so it needs no
setup of its own.

Reads the listing three ways — one unparameterised GET as Fetch issues it,
the same call followed through nextToken, and Fetch itself — then breaks the
result down by status, type, geography and vendor, counts distinct models
after normalization, and separates the ones the catalog can price from the
ones it cannot.

Not for merge.
2026-08-23 16:27:52 +00:00
mlsmaycon
011e96f0fe [debug] Sign with SigV4 and print the vendor's refusal
Two things the first pass could not do.

An environment may carry SigV4 credentials rather than a Bedrock API key,
and skipping there wastes the only account within reach. It now signs with
the default credential chain when no bearer token is set, and records which
mode it used. Fetch is bearer-only, so its step is skipped under SigV4 —
itself worth knowing, since it means a record holding an access key cannot
discover at all.

A non-200 printed nothing but its status. The body is the whole point of a
failure here: an IAM denial names the action it refused, which is a
different fix from a credential AWS does not recognise.
2026-08-23 16:25:22 +00:00
mlsmaycon
36cd7a5915 [debug] Decompose a Bedrock inference-profile listing
Not for merge. Discovery reports 100+ models for an account whose console
shows 38 in the same region, and the production path cannot explain it:
parseListing keeps an id, a name and a status and drops the rest of every
summary, so the type, the geography and whether a nextToken was present
never reach a log line.

Reads the listing three ways — one unparameterised GET as Fetch issues it,
the same call followed through nextToken, and Fetch itself — then breaks
the result down by status, type, geography and vendor, counts distinct
models after normalization, and lists which of those the catalog can
price.

Carries its own build tag, so no ordinary test run or CI job compiles it,
and needs only the token: no docker, no management server.
2026-08-23 16:22:13 +00:00
mlsmaycon
80c68bd6f1 [shared] Identify a Bedrock geography by the vendor that follows it
The cross-region inference-profile prefix was matched against a list of
four — us, eu, apac, global — so a profile issued under any other
geography kept its prefix through normalization. That form matches no
catalog key, which cost more than a blank price column:

  - discovery returned those models unpriced, so a real account's listing
    came back almost entirely at $0
  - the cost meter keys its table by the same normalized id, and operators
    are told to register a Bedrock id exactly as AWS issues it, region
    prefix included — so the default entry never resolved and every cache
    bucket, and any model priced only by catalog defaults, metered free

Identify the geography by what follows it instead: a leading segment is a
geography when a known Bedrock vendor namespace comes next. New
geographies then need no change at all, and a vendor missing from the map
fails safe by keeping the prefix — the behaviour of the list this
replaces. Over-stripping is the direction that must not happen, since the
normalized id also decides which route may claim a model.

Covered at all three seams the id passes through: the normalizer, the cost
meter config the proxy bills from, and the discovery listing the dashboard
renders. Each test fails against the old four-geography list.
2026-08-23 16:05:22 +00:00
mlsmaycon
84dda2ba8a [proxy] Authorise a Bedrock profile lookup against the model table
GetInferenceProfile was routed by provider type alone, so any caller with a
Bedrock route could read the full configuration of every profile in the
account — name, ARN, and underlying models — including profiles its policy
never named. The listing beside it is bounded on the way back, but a detail
lookup answers with a single object no filter inspects, so nothing narrowed
it.

Authorise the named profile like any other per-model request, as the
/v1/models/{id} lookup already is. The identifier is normalised first: a
record may register the raw profile id AWS issues or the catalog key it
reduces to, and either spelling must resolve. The listing itself names no
profile and stays model-less.
2026-08-23 12:19:00 +00:00
mlsmaycon
27cde6a9d3 [misc] Report a failed discovery by its shape, not its body
A discovery failure in the live e2e rendered the vendor's response into the
assertion message. When the vendor is Bedrock, that response is an AWS
refusal naming the resource it refused, and the name is an ARN carrying the
12-digit account id — into a job log anyone who can see the run can read.
The earlier change kept the body out of the success log and left the failure
paths quoting it, which is the path that actually carries the refusal.

Report the response's size and top-level keys instead. That is what the
failure is diagnosed from anyway: which envelope arrived, not what was in it.
2026-08-23 08:26:48 +00:00
mlsmaycon
d9f1ab63de [misc] Keep vendor listing bodies out of the job log
The discovery e2e echoed each vendor's whole response into the log. A
Bedrock listing embeds inference-profile ARNs, and an ARN carries the
12-digit AWS account id — so every run published one to a log anyone who
can see the run can read. Vertex project-scoped responses echo the
project id the same way.

Log the status instead. The ids line that follows is the finding, and
the assertion messages still carry the body, but those render only on a
failure that needs diagnosing.
2026-08-23 08:26:48 +00:00
mlsmaycon
6ce6a66ca7 [misc] Let the agent-network e2e run one package
A dispatch can now narrow the run to a single package instead of paying
the sixteen minutes the container suite costs, which is the difference
between iterating on one test in under a minute and not iterating on it.

The pattern reaches the shell through an env var rather than being
interpolated into the run script, since a dispatch input landing
directly in a shell command is a script-injection seam however trusted
the dispatcher is.
2026-08-23 08:26:48 +00:00
mlsmaycon
10b5822312 [misc] Teach the discovery e2e the Bedrock listing envelope
The live run proved the routing change: Bedrock answered
GET /inference-profiles with 200 and real inference profiles from the
control plane. The test then failed anyway, because its own id extractor
only knew {"data":[{"id":…}]} and reported the response as 'not a
listing' — the one shape the proxy had just been taught to filter.

Read both envelopes here, for the same reason the filter reads both: the
two have to stay in step, or this test contradicts the code it covers.
2026-08-23 08:26:48 +00:00
mlsmaycon
d3204b82f0 [proxy,management] Serve Bedrock model discovery from the control plane
A Bedrock provider could never answer a discovery request. The router
routed GET /inference-profiles to the record's upstream, which has to be
bedrock-runtime.<region> for InvokeModel to work, and that host does not
implement the operation — AWS answers <UnknownOperationException/>.
ListInferenceProfiles lives on the control plane at bedrock.<region>.

Give the route a discovery host, taken from the catalog's declaration
with the region read back out of the configured upstream, and send the
listing — and only the listing — there. Inference is untouched, and a
proxied or self-hosted Bedrock endpoint gets no discovery host at all
rather than a guessed one, since inventing a host would send the
operator's credential somewhere they never configured.

Two things had to follow for the listing to be usable once it arrives.

The response filter only understood OpenAI's {data:[{id:…}]}, so a
Bedrock listing was forwarded whole — offering every profile in the
account whatever the policy said. It now recognises the
inferenceProfileSummaries envelope, and matches a listing id against the
record's models after stripping the region prefix and version suffix, so
the two spellings of one model line up.

The policy bound had the same problem from the other side: it intersected
by exact string, so a record registering the raw profile id while a
guardrail names the catalog key intersected to nothing and would have
bounded a working provider's listing down to empty. routeClaimsModel
already normalises the candidate for this reason; the bound now agrees
with it.

The live discovery e2e flips from asserting the 404 to asserting a real
filtered listing. The mock upstream cannot cover any of this: it answers
/inference-profiles on the same listener as everything else, so a
mock-based test passes whichever host the request went to.
2026-08-23 08:26:48 +00:00
mlsmaycon
09cb67ffd5 [management] Return default rates with each discovered model
The endpoint reported pricing_known and then made the operator find the
price themselves. The dashboard has nothing to prefill a model row with, so
a discovered model either arrived at zero — silently metering every request
against it as free — or had to be priced by hand against a table NetBird
already ships.

Each model now carries the rates it would actually be billed at.

Rates come from the live default pricing table rather than the compiled-in
catalog, because that is the table the synthesiser ships to the proxy: an
operator running a defaults_llm_pricing.yaml would otherwise be shown one
price in the form and charged another. It is the same lookup the catalog
endpoint prefills from, so a model reached by either route prices
identically — pinned by TestDiscoveredRatesMatchTheCatalogEndpoint, since
the two are separate call paths that would otherwise drift.

pricing_known now derives from that same lookup instead of a second pass
over the compiled catalog, so "we can price this" and "here is the price"
can no longer disagree.

input_per_1k and output_per_1k are required and sent even at zero: an
unpriced model is offered at zero and flagged rather than withheld — the
vendor says the credential can reach it, and hiding it would hide a model
the operator genuinely has. The cache rates stay absent when unset, matching
the catalog response, because a zero there reads as "free" rather than "not
applicable".
2026-08-23 10:22:08 +02:00
mlsmaycon
67b1373dfe [management] Close the review's nitpicks on live model discovery
Five smaller points from the same review as the four already fixed.

**The no-redirect policy had no test.** Every Fetch test injects an
HTTPClient, which bypasses httpClient() and therefore the policy entirely,
so nothing asserted that a 302 is refused — and the policy is a security
control: a redirect moves the request to a host checkPublicHost never
resolved. TestRedirectsAreNotFollowed drives the real constructor against an
httptest server that redirects to the cloud metadata address, and asserts
exactly one request leaves the client.

**An injected client silently lost that policy.** Production is safe today
only because NewManager passes a nil HTTPClient; any future non-test
injection would have dropped the guarantee with no signal. An injected
client that states no policy now inherits ours.

Implemented by copying the client rather than assigning into it. Writing
c.HTTPClient.CheckRedirect from httpClient() would mutate a struct shared by
every request goroutine for the process's lifetime — a data race, and the
exact hazard the same review's next point warns about. The copy shares the
Transport, which is safe for concurrent use by design.

**Two comments described things that were not true.** The manager's doc
claimed reading a stored credential "is permission-checked as one"; there is
no per-record Read check, just the single Create check, which covers it
because Create is stronger and the lookup is account-scoped. Said that
instead. The modelDiscovery field now records that it is shared across
requests and must stay read-only after construction.

**The handler test asserted neither the label contract nor the upstream.**
The response omits label entirely when a vendor supplies none, and the
dashboard falls back to the id on absence — an empty string would render a
blank row. The fixture had no label-less model to prove it with (the review
described one, but both existing entries carry labels), so this adds one.
The upstream assertion matters because Bedrock's region is read back out of
it. Also fixes the "blank catalog provide" subtest name.
2026-08-23 10:22:08 +02:00
mlsmaycon
ca59f9f7a8 [management] Close four review findings on live model discovery
Four points from the review of #7246, all confirmed against the code.

**regionFromUpstream panicked on Bedrock's regionless endpoint.** The
template is "bedrock-runtime.<region>.amazonaws.com", so the two fixed
halves are "bedrock-runtime." and ".amazonaws.com". The regionless host
"bedrock-runtime.amazonaws.com" carries both at once, with the halves
overlapping rather than sandwiching a region — it satisfied HasPrefix and
HasSuffix, then sliced host[16:15]:

    panic: runtime error: slice bounds out of range [16:15]

That is reachable from any operator who types that host into upstream_url on
a Bedrock record. The length check makes the overlap read as "no region
here", which is what it is.

**A DNS-rebinding window sat between the guard and the dial.**
checkPublicHost resolved the host and the transport resolved it again to
dial, and the name's owner picks both answers. Public to the first lookup,
127.0.0.1 to the second, and the request reached loopback carrying the
operator's provider credential. The dialer now re-checks at the socket
through net.Dialer.Control, which runs post-resolution and pre-connect for
each address tried, so it sees what the second lookup actually returned.
The transport is cloned from http.DefaultTransport to keep its proxy and
TLS behaviour, and shared package-wide so the connection pool survives.

**Caller-input failures answered 500.** An unknown provider, an unusable
upstream, a region that cannot be read and a missing key are all reachable
from a well-formed request with a bad field value, and the OpenAPI document
already declares 400 for this endpoint. They now carry ErrInvalidRequest and
the handler branches on the sentinel rather than on message text. The
non-public-address refusal is included: that is the caller's own URL.

**The catalog id was trimmed for the emptiness test and then discarded.** A
padded " openai_api " cleared the check and reached catalog.Lookup with its
spaces, so the operator was told their provider was unknown.
2026-08-23 10:22:08 +02:00
mlsmaycon
5a49b58d5d [management] Expose live model discovery on the provider API
Adds POST /api/agent-network/catalog/providers/models, so the provider
form can offer the models an operator's own credential can reach instead
of only the compiled-in catalog.

A caller names a catalog provider and supplies either the key they are
typing (the record does not exist yet) or the id of a saved record whose
stored credential should be reused — which lets the dashboard refresh a
list without ever holding the key. The two are mutually exclusive:
accepting both would run an arbitrary credential under the identity of a
record the caller may only be permitted to read. When a record id is
given, the catalog id and upstream come from the record too, so the
credential cannot be aimed at a different vendor's endpoint.

Gated on Create rather than Read. This spends the operator's credential
against a third party, which is not something a read-only role should be
able to make the server do.

A provider with no listing endpoint answers 422 rather than 500: the
caller falls back to the catalog's own models on that outcome, so it has
to be distinguishable from a failure.

The region is read back out of the configured upstream by matching it
against the catalog's host template, since a provider record has no
region field and the operator already encoded one when they set up
inference. An upstream matching no template is refused rather than
guessed at — a wrong region would dial another account's endpoint.
2026-08-23 10:22:08 +02:00
mlsmaycon
427cfaccbf [management] Fetch a provider's model list from the vendor
The catalog is the only source of models an operator can pick from, and
it cannot know two things that matter. It goes stale — its entries carry
comments recording which models a vendor retired on which date — and it
cannot see an account: which OpenAI models an org is entitled to, which
Bedrock inference profiles a given account and region hold, which Vertex
models a project has enabled.

Add a client that asks the vendor directly, with the endpoint, auth
header and response shape all declared by the catalog rather than
supplied by the caller. The four shapes come from probing the live APIs
(see the discovery e2e); each vendor invented its own envelope and none
can be guessed from the request.

Bedrock is the case that shaped the design. Its listing lives on the
control plane while inference must go to the runtime host, so Discovery
carries its own host. The ids it returns are region-prefixed and are
taken verbatim, because that prefix is what AWS requires and it cannot
be derived from the configured region — an eu-central-1 account holds
global.* profiles alongside its eu.* ones.

The vendor is authoritative for the id; the catalog stays authoritative
for pricing. A discovered model the shipped table cannot price is
reported as such, so it cannot be registered at a silent zero rate.

Management has not made outbound calls on an operator's behalf before,
and it holds a credential for every provider, so the host is checked
before dialing: every resolved address must be public, which covers the
cloud metadata address and NetBird's own overlay range, and redirects
are not followed since they would move the request to a host the check
never saw.
2026-08-23 10:22:08 +02:00
mlsmaycon
7d727e8d12 [misc] Fail joinClient setup through require rather than t.Fatalf
Review point on #7239: the two setup checks in joinClient reported through
t.Fatalf while every other check in the suite goes through require. Same
outcome, one less shape to read.

The WaitProxyPeer check stays behind an if rather than being passed straight
to require.NoError: the message interpolates the proxy container's whole log,
and require evaluates its arguments before it knows the assertion passed. As
written the fetch happens only on the failure it exists to explain.
2026-08-23 10:22:07 +02:00
mlsmaycon
dd7f26308d [e2e] Retry the endpoint probe when the tunnel is not up yet
ResolveProxyIP exists to wake the lazy proxy peer, and it retried only
curl exit 6 — DNS. The wake-up attempt that arrives before WireGuard has
brought the tunnel up fails with exit 7 instead, and that returned
immediately:

  no HTTP response from vast-azalea.netbird.local: exit status 7
  (curl: (7) Failed to connect ... after 0 ms)

So the one function whose job is to tolerate a not-yet-ready endpoint
failed on the readiness state it was written for, one second after the
client container reported ready. Retry both exit codes within the same
window; anything else would still be failing when the window closed and
still fails immediately.

Raise the access-log ingest window to 60s for the same reason. The proxy
streams each entry with a 10s send timeout of its own, so 30s left
barely three attempts of headroom before a test that had already got its
200 was failed for a row still in flight.
2026-08-23 10:22:07 +02:00
mlsmaycon
06bf7c19ca [e2e] Assert what Bedrock discovery actually does against AWS
The first live run answered the question the mock could not. OpenAI and
Anthropic both filter correctly against real catalogues — Anthropic's
dated claude-haiku-4-5-20251001 survives a record registering the
undated id, and OpenAI's listing comes back as the single model the
guardrail permits. Vertex is refused by the proxy, as intended.

Bedrock is the one that was wrong, and wrong about something worth
recording: GET /inference-profiles reaches AWS and AWS answers
<UnknownOperationException/>. ListInferenceProfiles is a control-plane
operation on bedrock.<region>.amazonaws.com; a provider record carries a
single upstream and it must be the runtime host for InvokeModel to work,
so no Bedrock record can serve a listing as the model stands. The mock
serves that path on the same listener as everything else, which is
exactly why this went unnoticed.

Replace the routed/filtered pair with an explicit outcome, since the
three cases are different contracts rather than degrees of success, and
tell apart 'the proxy refused' from 'the vendor refused' by whether the
body names a middleware — no upstream error body does. The two
non-listing outcomes now issue a single request instead of retrying for
the full window waiting on a status that is never coming, which is where
92 of the failing run's 136 seconds went.
2026-08-23 10:22:07 +02:00
mlsmaycon
46d94c13e7 [e2e] Drive model discovery against the live vendor endpoints
The mock upstream advertises ids we chose, so a listing narrowing to the
ones we authorised is arithmetic we controlled both sides of. It cannot
show the filter surviving a real catalogue: ids we never enumerated,
dated builds whose suffix the vendor picks, surfaces that answer a
listing request with something that is not a listing.

Cover the four surfaces against their real endpoints, each gated on its
own credential so a partial key set still yields partial coverage:

  - OpenAI enumerates two real models and the policy permits one, so
    both bounds are observable at once against a catalogue of dozens.
  - Anthropic returns dated build ids while the record registers the
    undated one, which exercises date-normalisation on ids the vendor
    chose. This is also the surface Claude Code actually calls.
  - Bedrock lists inference profiles rather than models; the request is
    routed but not model-bounded, since filtering keys on /v1/models.
  - Vertex serves no listing at all, so discovery must be refused rather
    than rewritten onto an upstream that would 404 it.

One proxy serves every case, with a group, policy and client per
provider: a model-less request matches exactly one route, so two
providers authorised for the same caller would leave one untested.

Every response is logged before anything is asserted on it. A live
catalogue is the one input the suite does not control, so a failure has
to arrive carrying the response that caused it.
2026-08-23 10:22:07 +02:00
mlsmaycon
4cae1026a4 [e2e] Prove the other team's policy is live in the discovery test
The discovery isolation test drove a single client in the main group.
VLLMUnlistedModel's absence from that client's listing was consistent
with two different worlds: the listing being scoped to the caller's
policy, or the other team's policy never having applied at all. The
test passed either way, so it did not prove what it claimed.

Mint a setup key per group and join a second client on the other
group, reusing the running proxy. The other client must see its own
model before the main client's listing is asserted, and must not see
the main group's model — isolation is checked in both directions.
2026-08-23 10:22:07 +02:00
mlsmaycon
f8a55ac8d4 [proxy,management] Bound model discovery to the caller's own policies
The listing was narrowed by the provider record's enumerated models, which is
the right bound only while one policy reaches a provider. Where two teams
share a provider under different allowlists, every caller was offered the
union: each model outside their own policy is a request the guardrail refuses
a moment later, which is the empty-or-wrong picker this endpoint exists to
avoid, moved one level up. A gateway record enumerating nothing was worse
still — it offered the upstream's entire catalogue however narrow the policy.

The synthesiser already knows which policies authorise a provider and which
groups each binds, so the router can answer this at request time where it
knows the caller's groups. Each route now carries one rule per authorising
policy — its source groups and the models it permits — and the listing is
bounded to the union across the rules matching the caller, intersected with
what the provider serves.

This is deliberately finer than the guardrail's own per-provider allowlist,
which stays as it is: that list is a fail-closed backstop and cannot tell who
is asking, so discovery is now narrower than the backstop rather than wider.
A policy setting no allowlist lifts the restriction for the groups it binds,
so nil and empty model lists stay distinct end to end — collapsing them would
let a listing that should offer nothing fall open to everything.
2026-08-23 10:22:07 +02:00
mlsmaycon
d3e0ee8547 [proxy] Fail a mapping update whose chains would not install
Publishing the chain before the route only helps if the chain is there.
rebuildMiddlewareChains logged its error and returned, so a failed rebuild
still fell through to AddMapping and published a route over chains that were
never installed — served with no policy enforcement and no metering, which
is the outcome the ordering change exists to prevent.

Report the error instead. The caller already unwinds a failed setup, so the
service stays unpublished rather than reachable and uncounted. An unset
middleware manager is still not an error: that is a deployment without
middleware, not a failure to install it.
2026-08-23 07:26:27 +00:00
mlsmaycon
17525a58bf [proxy] Publish a rebuilt chain before the route that reaches it
A provider update added the proxy mapping and then rebuilt the middleware
chain. Between the two, the route was live with no chain behind it, and a
request that landed there was served straight through — a successful
inference that was neither routed by policy nor metered.

Rebuild first. The worst a request in the remaining window meets is the new
chain in front of the previous target, which is still counted.
2026-08-23 07:20:17 +00:00
mlsmaycon
427b4c8d41 [misc] Keep the repricing loop inside its own deadline
The loop bounded when a new attempt could start, not how long one could
run. The chat container is capped at 90 seconds of its own and the row
lookup at another 20, so an attempt begun just inside the 180-second window
could report a repricing failure nearly two minutes after that window
closed. Run every call in the loop under a context that expires with the
deadline.
2026-08-23 06:36:37 +00:00
mlsmaycon
acfad1a384 [proxy] Strip only a gateway's own namespace before matching a model
Three open review findings.

The discovery filter treated every slash in a listed id as a gateway prefix
and matched the tail against the policy. A self-hosted id carries slashes of
its own, and an upstream may scope ids per tenant, so "tenant-b/claude-sonnet-5"
matched a permitted "claude-sonnet-5" and reached the picker — a model the
policy never named, and one the guardrail denies on sight, since enforcement
compares the id as written. Strip only the namespaces a gateway is known to
prepend, taken from the first slash rather than the last.

The e2e retry loops slept between attempts without watching the context, so a
cancelled run kept retrying calls that fail instantly and spent its remaining
window sleeping between them. They now stop when the context is done.

The streamed provider's setup key outlived its test: deleting the group does
not delete the key that auto-joins it.
2026-08-23 06:35:27 +00:00
mlsmaycon
a02aa16cae [misc] Report the cost the repricing loop actually saw
The failure message read repriced.InputCostUsd, and repriced is the zero
value on every path that reaches that line — so a run that gave up always
reported "last input_cost_usd=$0.000000", which reads as a row priced at
zero rather than as a row still at the old rate, or as no row at all.

Keep the last cost read and report that, saying so plainly when no row was
ever read.
2026-08-23 06:25:37 +00:00
mlsmaycon
9e1431084f [misc] End the access-log lookup when its window ends
The poll interval was not bounded by the window: a page that came back
without the row just before the deadline still slept a full two seconds
before the loop noticed it was out of budget, so the caller waited longer
than the window it asked for to be told nothing arrived.

Cap the wait at whatever is left of the window.
2026-08-23 06:20:55 +00:00
mlsmaycon
a6bcb177fe [misc] Bound each access-log poll, and assert the streamed total
Two review findings on the e2e suite.

lookupAccessLogBySession polled under the caller's context, so one stalled
request could hold the loop open well past the 30s ingest window it exists to
enforce — and the caller would read that delay as a missing row rather than a
slow one. Each poll now expires with the window; the parent's cancellation
still applies, since the request context derives from it.

The streaming test asserted only that the total cost was positive. Input and
output are positive on their own, so a cache-read bucket that was parsed and
then never billed would have passed. Assert the sum of the three buckets: the
gap a dropped cache read leaves is 7e-6, well outside the delta.
2026-08-22 22:00:39 +00:00
mlsmaycon
5c65dc9349 [proxy] Cover the gateway-protocol gaps end to end
Six behaviours had unit coverage only, either because they arrived from code
review after the end-to-end tests were written or because no request in the
suite had the shape that reaches them.

Streaming is the important one. Input tokens exist only in a stream's opening
message_start event, and reading a stream with the wrong vendor's parser
misses it — the metering bug this endpoint's protocol work fixed. Nothing in
the suite sent stream: true, so the branch never ran. The mock now serves an
SSE surface on a second listener, reporting counts that differ from its
buffered ones so a passing assertion can only mean the stream accumulator ran,
and one case drives it through a record typed for the wrong surface.

The rest need no new harness capability: the per-model lookup against the
allowlist, the read-method gate on the non-inference paths, dated ids reaching
an undated registration while a pinned build refuses a different one, the
Bedrock inference-profile lookup reaching its upstream rather than a policy
denial, and a custom dated id keeping its own price.

Sub-agent ids stay uncovered: the parser lifts them onto request metadata but
nothing persists them, so there is no queryable surface to assert against
until that half lands. Covered here only to the extent that sending the
headers leaves the request served and metered.
2026-08-22 21:14:05 +02:00
mlsmaycon
510d0e6653 [misc] Let the reprice e2e retry a request that never got logged
TestPriceChangeUpdatesRecordedCost drives requests in a loop until one is
priced at the new rate, because the price push and the proxy's chain rebuild
are async. The loop could not actually retry: it looked the row up through
findAccessLogBySession, which fails the test outright when no row lands
within 30s, so the first post-update request that produced no row ended the
run instead of yielding to the next attempt.

That is the observed failure — the nightly run has been red on this test
roughly half the time, always with "session id ...-reprice-b-... must be
recorded in an access-log row" after ~41s: container setup, one request, one
30s wait, dead.

A missing row there is expected rather than exceptional. The provider update
rebuilds the 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. Split the polling helper into a non-fatal lookup
and keep the fail-fast wrapper for callers whose row must exist, then treat a
miss in the loop like any other not-yet-repriced iteration. Only the outer
deadline is fatal.

Shorten the per-attempt wait to 20s and raise the overall deadline to 180s so
several attempts fit where before the budget allowed barely one.
2026-08-22 21:14:05 +02:00
mlsmaycon
07475213d3 [proxy] Keep deliberately pinned dated models distinct
Two changes to routeClaimsModel, both about dated Anthropic ids.

Normalising the configured candidate as well as the requested model made
every dated build of a family interchangeable: a route registered against
claude-sonnet-4-5-20250101 also claimed ...-20250202, so an operator who
pinned a build deliberately would have served a different one, and with
several such routes declaration or path order decided which. Only an undated
registration now absorbs a dated request.

The per-model lookup also stamps the model its path names, so the guardrail's
allowlist — a separate and possibly narrower list than the route's — still
decides GET /v1/models/{id} rather than seeing no model at all.
2026-08-22 21:14:05 +02:00
mlsmaycon
8f66ee2870 [proxy] Let model discovery past the provider allowlist
The guardrail enforces its own per-provider model allowlist and fails closed
when the request names no model, which is right for a path-routed inference
request whose shape the parser could not read. GET /v1/models names no model
anywhere, so discovery still denied with model_unknown for exactly the
accounts that configured an allowlist — the case skipping the management
pre-flight was meant to fix. Only one of the two gates had been opened, and
a client reads the 403 as an empty model picker.

Exempt requests the router marked non-inference from the unknown-model
branch. A named model is still checked, so the exemption covers only the
endpoints that genuinely name nothing: the listing and the warm probe.
2026-08-22 21:14:05 +02:00
mlsmaycon
a3c2e12571 [proxy] Forward an oversized model listing whole
The discovery filter read one byte past its 1 MiB cap to detect a body too
large to rewrite, then closed the upstream body and forwarded the buffer it
had — the response reached the client truncated at exactly the cap, with
Content-Length rewritten to match so nothing looked wrong until the client
tried to parse it.

Splice the bytes already read back in front of the unread remainder and
forward the response as the upstream sent it, headers untouched.
2026-08-22 21:14:05 +02:00
mlsmaycon
1d5ca910c0 [proxy] Name what the warm-probe assertions are pinning
The two assertions in the HEAD subtest carried no failure message, unlike the
rest of the file. Say which half failed.
2026-08-22 21:14:05 +02:00
mlsmaycon
693712391b [proxy] Gate the non-inference mark on a read method
The router classified a non-inference request by path alone, so a POST to
/v1/models/{id} — or to the listing, or the warm probe — was marked
llm.non_inference and skipped the limit check's management pre-flight, even
though such a request can carry an inference body.

Require GET or HEAD, the methods these endpoints actually use. Anything else
falls through to normal per-model routing, which routes on the body's model
under the usual pre-flight, or denies as missing-model when there is none.
2026-08-22 21:14:05 +02:00
mlsmaycon
e2470985fe [proxy] Split the router's surface dispatch out of Invoke
Invoke had grown a branch per API surface, each repeating the same
found/unauthorised/unknown switch. Lift the shared denial arms into decide(),
move the model-less endpoints into their own method, and name the two allow
decorations (non-inference marking, Bedrock namespace stripping) so each
surface reads as the one thing it does differently.

No behaviour change.
2026-08-22 21:14:05 +02:00
mlsmaycon
6fb339a35a [proxy] Authorise the per-model lookup against the model table
GET /v1/models/{id} was folded into the model-less endpoints so it would
route rather than deny. Once model-less requests started skipping the
management pre-flight, that also skipped the per-model allowlist: a caller
could confirm the existence and reachability of a model the route does not
list, even though the listing beside it is bounded to that same allowlist.

Resolve the id from the path and route it through the model table like any
other per-model request, keeping model-less treatment for the listing and
the connection-warming probe only. It stays marked non-inference, since the
lookup spends no tokens. A gateway route that enumerates no models still
answers every lookup, as before.

Also record why the Bedrock inference-profile lookup is forwarded rather
than denied: those live on the AWS control plane, and forwarding reproduces
what an unproxied client with the same base URL would see.
2026-08-22 21:14:05 +02:00
mlsmaycon
0b8cf14530 [proxy] Anchor the Anthropic date strip to Claude ids
The release-date normalizer matched a bare "-YYYYMMDD" suffix on any id.
Pricing looks every model up through it regardless of surface, and an
operator can register a custom model under any id at all, so a custom
"internal-llm-20250101" would silently inherit the rate registered for
"internal-llm".

Anchor the pattern on "claude" so it still covers the vendor-prefixed
Bedrock forms while leaving every other vendor's id untouched.
2026-08-22 21:14:05 +02:00
mlsmaycon
3538562bb1 [proxy] Add e2e cover for the gateway protocol changes
The routing and parser-selection fixes touch every provider surface, and
the unit tests only prove each side of a seam in isolation. Two suites
close that:

The provider matrix drives one request per wire shape over a single tunnel,
with a record per catalog surface behind it, and asserts the surface each
request was metered under together with the token counts that surface's own
usage block carries. A response read by the wrong provider's parser meters
zero, so a regression fails instead of passing on a coincidental non-zero.
It also covers the Bedrock and Vertex token-counting paths, the warm-up
probe, and the vendor error envelope on a refusal.

The discovery suite covers the configuration that broke: an account with a
model allowlist, where the listing carries no model and the gate failed
closed. It asserts the listing is served, that it is bounded to the
authorised model, and that inference outside the allowlist is still
refused, so the exemption cannot be read as a way around the gate.

The mock upstream grows the Anthropic, Bedrock and token-counting shapes so
one container stands in for every surface, and the client gains GET and
arbitrary-POST helpers for the endpoints that carry no chat body.
2026-08-22 21:14:05 +02:00
mlsmaycon
babc7a5446 [proxy] Keep slash-bearing model ids in the discovery filter
The filter treated a slash in a listing entry's id as a gateway provider
prefix and matched only the tail. Self-hosted backends serve ids that carry
a slash of their own, so every "Qwen/Qwen2.5-0.5B-Instruct" style model was
dropped from the picker even when the policy named it exactly.

Try the id as written first and fall back to the tail, so both a prefixed
id and a self-hosted one resolve.
2026-08-22 21:14:05 +02:00
mlsmaycon
7a9fcc25c9 [docs] Document the client checks that bypass the agent network endpoint
A few client-side checks call their vendor directly instead of following
the configured base URL, so they fail on exactly the locked-down networks
Agent Network is built for while inference keeps working. Fast mode reports
a connectivity error, or reports itself disabled by the organization when
the agent holds only a proxy-issued token, and model discovery stays off
until it is turned on explicitly.

Name the variables that settle each case, and say plainly which ones
allowing direct egress does not fix.
2026-08-22 21:14:05 +02:00
mlsmaycon
ba05d85bae [proxy] Capture sub-agent ids from LLM request headers
A coding agent that spawns helpers stamps the spawned agent's id on every
request it makes, and the spawning agent's id when that helper is nested.
The parser read the session header and ignored both, so parallel agents
inside one session all attributed to the session alone and there was no way
to see which one spent the tokens.

Emit them as metadata alongside the session id. They are opaque grouping
identifiers rather than content, so they are stamped regardless of the
prompt-collection toggle. Persisting them as queryable access-log columns
is a schema change and is deliberately not part of this commit.
2026-08-22 21:14:05 +02:00
mlsmaycon
2ab637e885 [proxy] Bound the model-listing response to what policy authorises
Discovery proxies the upstream's full list, so the picker offers every
model the shared provider key can reach and each one outside the policy is
a request the chain denies a moment later. Restricting models is the point
of the product, and the client had no way to see the restriction.

Carry the resolved route's model list on the upstream rewrite and drop the
rest from the listing response. Only a route that enumerates its models
bounds anything: a catch-all claims every model, so its list passes
through. Anything the filter cannot safely rewrite, including a compressed
or oversized body, reaches the client untouched.
2026-08-22 21:14:05 +02:00
mlsmaycon
3d0a6e2f00 [proxy] Forward the Anthropic connection-warming probe
Clients send HEAD /api/hello before their first inference request to open
the upstream connection early. The path carries no model, so it denied as
not-routable and each session start left a policy rejection in the access
log for a request that was never a policy question.

Treat it as a model-less endpoint. Forwarding it warms the connection the
first real request will use, which is what the probe is for.
2026-08-22 21:14:05 +02:00
mlsmaycon
6545034b09 [proxy] Route Bedrock inference-profile lookups
A client resolving a configured inference profile calls
GET /inference-profiles at startup. The path carries no model and was not
recognised as non-inference, so it denied as not-routable and wrote a
policy rejection into the access log on every session start, which is the
log operators read to find real policy problems.

Recognise the path and match it against a Bedrock provider specifically:
sending it to whichever provider happened to be authorised first would
rewrite it to an upstream that 404s it. The optional gateway namespace is
stripped the same way the runtime paths strip it.
2026-08-22 21:14:05 +02:00
mlsmaycon
469bfffa36 [proxy] Keep the Vertex model id out of the count-tokens method segment
Vertex hangs token counting off the model as its own path segment, and the
parser split the tail on the final colon alone. A count-tokens request
therefore reported its model as "claude-sonnet-5/count-tokens", which no
route claims, so the request denied as not-routable and the access log
recorded a model that does not exist.

Stop at the first "/" after the model id so the method segment stays out of
it, leaving the client free to price its context against the dedicated
endpoint instead of the billable inference one.
2026-08-22 21:14:05 +02:00
mlsmaycon
d0340a9e2f [proxy] Route the Bedrock count-tokens action
Both the request parser and the router enumerated Bedrock actions without
count-tokens, so the path carried no model and the request denied as
not-routable. Nothing breaks outright, because the client falls back to
counting context through the inference endpoint, but that fallback is
billable and the dedicated endpoint exists to avoid exactly that.

The action carries a model in the path and returns no usage, so it routes
like any other Bedrock action and meters to zero.
2026-08-22 21:14:05 +02:00
mlsmaycon
1949a234e7 [proxy] Mirror LLM denials in the caller's provider error shape
A budget stop, a blocked model or an unroutable model all rendered as the
NetBird deny envelope alone. LLM clients only parse their own provider's
error shape, so the reason never reached the user: Claude Code showed an
unexplained API error where it could have shown the policy message.

Carry the resolved surface on the deny reason and add the vendor's error
object next to the existing fields. The body stays a superset of what it
was, so anything reading code, message, details or middleware is
unaffected. Status codes are unchanged here: mapping window caps to 429
needs the window reset plumbed through the limits response before a
correct Retry-After can be sent.
2026-08-22 21:14:05 +02:00
mlsmaycon
00a4f7dee1 [proxy] Skip OpenAI-shape identity injection on non-OpenAI bodies
Gateway records enable body-level identity so LiteLLM's tag-budget check
can read it, and the injector wrote "user" and "metadata.tags" into every
JSON object regardless of dialect. Claude Code reaches those same records
on /v1/messages, where "user" is not a permitted top-level field and
metadata accepts only "user_id", so the upstream rejected the request with
a 400 naming a field the client never sent. Rewriting the body also
changed the bytes a gateway-side prompt cache keys on.

Gate the body write on the surface llm_request_parser resolved from the
path. Header stamping is untouched, so spend tracking and per-end-user
budgets keep working on the surfaces that lose the body path.
2026-08-22 21:14:05 +02:00
mlsmaycon
119b70c664 [proxy] Match dated Anthropic model ids against their undated form
shared/llm normalizes Bedrock and Vertex model ids so both sides of the
routing and pricing contract compare equal, but nothing did the same for a
first-party Anthropic id. A client pinning "claude-sonnet-4-5-20250929"
against a record registered as "claude-sonnet-4-5" denied as not-routable,
and where a catch-all route carried it through, the price lookup missed and
the request recorded no cost.

Add NormalizeAnthropicModel beside the existing two and consult it after an
exact match fails in the router's claim check, the pricing table, and the
per-record price map. Exact matches still win, so an operator who registers
two dated releases of the same family keeps them distinct.
2026-08-22 21:14:05 +02:00
mlsmaycon
743c18fb62 [proxy] Exempt non-inference endpoints from the model allowlist gate
GET /v1/models carries no model, and management's per-model allowlist
fails closed on an undetermined one, so gateway model discovery denied
with model_blocked for every account that enables a model allowlist. The
client treats a failed discovery as silent and falls back to its built-in
list, so the operator sees an empty picker with no error to chase.

The router already classifies these paths and authorises the route against
the caller's groups before allowing them, so mark them non-inference there
and let the limits gate skip a pre-flight that has no model to evaluate
and no tokens to book. The marker comes from the router's own path
classification, never from client input, so an inference request cannot
set it to escape the allowlist.
2026-08-22 21:14:05 +02:00
mlsmaycon
ed7eec7c3e [proxy] Select the LLM parser by request path before provider_id
Gateway catalog entries pin provider_id "openai", and the same record
serves Claude Code on /v1/messages. The parser preferred the pinned id
over the path, so an Anthropic body was read with the OpenAI parser: on a
streaming response the input tokens ride message_start nested under
message, which that parser never reads, so input counted as zero. Both
cache buckets were dropped, and pricing resolved against the openai
surface where no claude-* model exists, skipping cost entirely.

Detect from the path first and keep provider_id as the fallback for
upstreams whose path carries no surface. The Kimi entry already leaves
ParserID empty to work around this; the fallback ordering makes that
unnecessary.
2026-08-22 21:14:05 +02:00
mlsmaycon
9ad1d9a39a [management] Add the Claude 5 lineup to the Agent Network catalog
Claude Code resolves to Opus 5 and Sonnet 5 by default, and neither was
selectable on a provider record. An operator building a record from the
catalog could not authorise the client's own default, so llm_router denied
those requests as model_not_routable. Opus 5 carried a supplemental pricing
row that priced gateway traffic but never reached the dashboard; Sonnet 5
was absent everywhere, so a request that did route through a catch-all
gateway recorded zero cost and under-counted every budget it should have
charged.

Add both to the Anthropic, Bedrock and Vertex lineups at the published
rates, and drop the supplemental rows now that the catalog carries them.
2026-08-22 21:14:05 +02:00
18 changed files with 299 additions and 1206 deletions

View File

@@ -96,33 +96,6 @@ components:
— the management-side control plane: providers, policies, guardrails, limits, routing,
and usage/access logs.
## Access roles
Agent Network permissions build on the account permission matrix
([`management/server/permissions/`](../management/server/permissions)). The
`agent_network` area is split into dotted submodules (`agent_network.providers`,
`.policies`, `.guardrails`, `.budgets`, `.usage`, `.logs`, `.settings`); a role may
grant a single submodule or the parent, which cascades to all of them.
Two roles delegate Agent Network access without account-admin rights:
- **`agent_network_admin`** — full control over the whole `agent_network` area plus
read-only users, groups, peers, and account info (needed to build policies).
Nothing else in the account.
- **`usage_viewer`** — the regular User baseline plus read on
`agent_network.usage` (the aggregated usage and cost overview) and read-only
access to the resources the usage filters resolve against: users, groups,
peers, and the provider list. No policies, no request-level access logs.
Every authenticated user, regardless of role, can read the caller-scoped
self-service endpoint `GET /api/agent-network/me/setup` (the endpoint, providers,
and models the caller's own policies allow — what a local AI tool needs and nothing
more). The regular usage and access-log endpoints self-scope instead of denying:
a caller without the account-wide grant gets their own rows back, so "my usage"
and "my requests" are the same endpoints the admin dashboard uses. Role
definitions live in
[`management/server/permissions/roles/`](../management/server/permissions/roles).
## Documentation
Full documentation, architecture, and quickstart:

View File

@@ -0,0 +1,254 @@
//go:build e2e
package agentnetwork
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
sharedllm "github.com/netbirdio/netbird/shared/llm"
)
// TestDebugBedrockProfileCount investigates a listing that reports 100+ models
// for an account whose console shows 38 in the same region. It asserts almost
// nothing — it prints what the production path throws away.
//
// parseListing keeps an id, a name and a status and discards the rest of every
// summary, so the type, the geography and whether a nextToken came back never
// reach a log line. Fetch then works from a list that has already been
// filtered. Neither can answer where the surplus comes from.
//
// It reads the listing three ways:
//
// [1] one GET with no query parameters — byte for byte what Fetch issues,
// which shows how much of the account a single page carries
// [2] the same call followed through nextToken, for the real total
// [3] Fetch itself, for what reaches the dashboard
//
// then decomposes the full set by status, type, geography and vendor, and
// counts distinct models after normalization. Two outcomes need opposite
// fixes and look identical in the dashboard:
//
// - distinct-after-normalization lands near the console's count → the
// surplus is one model offered once per geography, and the question is
// what to offer rather than what broke
// - it does not → we are being handed profiles the console does not show,
// and the filter is what to look at
//
// Uses the same credential as the rest of the live suite:
//
// go test -tags e2e ./e2e/agentnetwork/ -run TestDebugBedrockProfileCount -v
func TestDebugBedrockProfileCount(t *testing.T) {
token := os.Getenv("AWS_BEARER_TOKEN_BEDROCK")
if token == "" {
t.Skip("AWS_BEARER_TOKEN_BEDROCK not set; source ~/.llm-keys to run the Bedrock count debug")
}
region := os.Getenv("AWS_REGION")
if region == "" {
region = "eu-central-1"
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
host := "bedrock." + region + ".amazonaws.com"
t.Logf("=== region %s, control plane %s ===", region, host)
// [1] Exactly what Fetch asks for: no maxResults, no type filter.
first, firstRaw := listInferenceProfiles(t, ctx, host, token, nil)
t.Logf("[1] production-shaped call: %d summaries, %d bytes, nextToken present: %t",
len(first.Summaries), len(firstRaw), first.NextToken != "")
// [2] Followed to exhaustion, so the total is not just a page size.
all := append([]bedrockProfileSummary(nil), first.Summaries...)
next, pages := first.NextToken, 1
for next != "" && pages < 20 {
page, _ := listInferenceProfiles(t, ctx, host, token, map[string]string{"nextToken": next})
all = append(all, page.Summaries...)
next, pages = page.NextToken, pages+1
}
t.Logf("[2] paginated: %d summaries across %d page(s)", len(all), pages)
if next != "" {
t.Logf(" WARNING: stopped at the page cap with a nextToken still outstanding")
}
// [3] The path the Load models button drives, with its ACTIVE filter and
// its dedup.
var cl modeldiscovery.Client
fetched, err := cl.Fetch(ctx, modeldiscovery.Request{
CatalogID: "bedrock_api",
UpstreamURL: "https://bedrock-runtime." + region + ".amazonaws.com",
APIKey: token,
})
require.NoError(t, err, "Fetch must reach the control plane")
t.Logf("[3] Fetch returned %d models (this is what the dashboard renders)", len(fetched))
if len(first.Summaries) == len(all) && len(fetched) > len(all) {
t.Logf(" NOTE: Fetch returned more than the raw listing — the surplus is ours, not AWS's")
}
byStatus, byType, byGeo, byVendor := map[string]int{}, map[string]int{}, map[string]int{}, map[string]int{}
normalized := map[string]struct{}{}
perModel := map[string][]string{}
active := 0
for _, s := range all {
byStatus[orAbsent(s.Status)]++
byType[orAbsent(s.Type)]++
geo, vendor := splitBedrockProfileID(s.ID)
byGeo[geo]++
byVendor[vendor]++
if s.Status != "" && !strings.EqualFold(s.Status, "ACTIVE") {
continue
}
active++
key := sharedllm.NormalizeBedrockModel(s.ID)
normalized[key] = struct{}{}
perModel[key] = append(perModel[key], s.ID)
}
t.Logf("--- ACTIVE summaries: %d of %d", active, len(all))
t.Logf("--- distinct models after normalization: %d <<< compare with the console", len(normalized))
logProfileCounts(t, "by status", byStatus)
logProfileCounts(t, "by type", byType)
logProfileCounts(t, "by geography", byGeo)
logProfileCounts(t, "by vendor", byVendor)
var repeated []string
for key, ids := range perModel {
if len(ids) > 1 {
sort.Strings(ids)
repeated = append(repeated, key+" ("+strings.Join(ids, ", ")+")")
}
}
sort.Strings(repeated)
t.Logf("--- models offered under more than one geography: %d", len(repeated))
for _, line := range repeated {
t.Logf(" %s", line)
}
// A model the catalog cannot price is a catalog gap, not a normalization
// failure. Both render as $0 with a yellow border and need opposite fixes.
entry, ok := catalog.Lookup("bedrock_api")
require.True(t, ok)
var priced, unpriced []string
for id := range normalized {
if _, known := pricing.LookupDefault(entry.PricingSurfaces, id); known {
priced = append(priced, id)
continue
}
unpriced = append(unpriced, id)
}
sort.Strings(priced)
sort.Strings(unpriced)
t.Logf("--- priced by the catalog: %d", len(priced))
for _, id := range priced {
t.Logf(" + %s", id)
}
t.Logf("--- NOT priced by the catalog: %d (catalog coverage, not normalization)", len(unpriced))
for _, id := range unpriced {
t.Logf(" - %s", id)
}
}
type bedrockProfileSummary struct {
ID string `json:"inferenceProfileId"`
Name string `json:"inferenceProfileName"`
Status string `json:"status"`
Type string `json:"type"`
ARN string `json:"inferenceProfileArn"`
}
type bedrockProfilePage struct {
Summaries []bedrockProfileSummary `json:"inferenceProfileSummaries"`
NextToken string `json:"nextToken"`
}
// listInferenceProfiles calls the control plane directly so the whole summary
// is visible, rather than the three fields parseListing keeps.
func listInferenceProfiles(t *testing.T, ctx context.Context, host, token string, query map[string]string) (bedrockProfilePage, []byte) {
t.Helper()
target := url.URL{Scheme: "https", Host: host, Path: "/inference-profiles"}
if len(query) > 0 {
q := target.Query()
for k, v := range query {
q.Set(k, v)
}
target.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err, "reach the Bedrock control plane")
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
require.NoError(t, err)
if resp.StatusCode != http.StatusOK {
// The body is the point of a failure here: an IAM denial names the
// action it refused, which is a different fix from a bad token.
t.Logf("control plane answered %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
t.Logf(" x-amzn-errortype: %s", resp.Header.Get("x-amzn-errortype"))
}
require.Equal(t, http.StatusOK, resp.StatusCode, "control plane must answer the listing")
var page bedrockProfilePage
require.NoError(t, json.Unmarshal(raw, &page), "listing must parse")
return page, raw
}
// splitBedrockProfileID reports the geography and vendor segments of a
// profile id.
func splitBedrockProfileID(id string) (geo, vendor string) {
parts := strings.SplitN(id, ".", 3)
switch len(parts) {
case 3:
return parts[0], parts[1]
case 2:
return "(none)", parts[0]
default:
return "(none)", "(none)"
}
}
func orAbsent(s string) string {
if s == "" {
return "(absent)"
}
return s
}
func logProfileCounts(t *testing.T, label string, counts map[string]int) {
t.Helper()
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if counts[keys[i]] != counts[keys[j]] {
return counts[keys[i]] > counts[keys[j]]
}
return keys[i] < keys[j]
})
t.Logf("--- %s:", label)
for _, k := range keys {
t.Logf(" %-30s %d", k, counts[k])
}
}

View File

@@ -1,56 +0,0 @@
package handlers
import (
"net/http"
"github.com/gorilla/mux"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
)
// addMeEndpoints registers the self-service "My Agent Network" route.
// It is available to every authenticated user regardless of role: the
// response is scoped strictly to the caller, which is tighter than any
// role gate could be. The caller's own usage and requests are served by
// the regular usage/logs endpoints, which self-scope for callers without
// the account-wide grants.
func (h *handler) addMeEndpoints(router *mux.Router) {
router.HandleFunc("/agent-network/me/setup", h.getMySetup).Methods("GET", "OPTIONS")
}
func (h *handler) getMySetup(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
setup, err := h.manager.GetSetupForUser(r.Context(), userAuth.AccountId, userAuth.UserId)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, setupToAPI(setup))
}
func setupToAPI(setup *types.EffectiveSetup) api.AgentNetworkMeSetup {
providers := make([]api.AgentNetworkMeProvider, 0, len(setup.Providers))
for _, p := range setup.Providers {
providers = append(providers, api.AgentNetworkMeProvider{
Name: p.Name,
CatalogId: p.CatalogID,
ApiFlavor: p.APIFlavor,
AllModelsAllowed: p.AllModelsAllowed,
Models: p.Models,
})
}
return api.AgentNetworkMeSetup{
Configured: setup.Configured,
Endpoint: setup.Endpoint,
Providers: providers,
}
}

View File

@@ -46,7 +46,6 @@ func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) {
h.addConsumptionEndpoints(router)
h.addAccessLogEndpoints(router)
h.addBudgetRuleEndpoints(router)
h.addMeEndpoints(router)
}
func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {

View File

@@ -85,13 +85,6 @@ type Manager interface {
RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error
RecordUsage(ctx context.Context, in RecordUsageInput) error
SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error)
// GetSetupForUser backs the self-service "My Agent Network" setup
// endpoint. Caller-scoped, so it skips the role permission gate; see
// the implementation. The caller's own usage and requests come
// through GetUsageOverview / ListAccessLogs, which self-scope when
// the account-wide grant is missing.
GetSetupForUser(ctx context.Context, accountID, userID string) (*types.EffectiveSetup, error)
}
// PolicySelectionInput is the per-request selection envelope. The
@@ -952,11 +945,8 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
// ListAccessLogs returns a paginated, server-side-filtered page of
// agent-network access logs plus the total count matching the filter.
// Callers without the account-wide logs grant get a self-scoped page —
// only their own requests — instead of a denial.
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter)
if err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
@@ -964,23 +954,18 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
// ListAccessLogSessions returns a paginated, server-side-filtered page of
// agent-network access logs grouped by session, plus the total number of
// sessions matching the filter. Self-scoped like ListAccessLogs for
// callers without the account-wide logs grant.
// sessions matching the filter.
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkLogs, filter)
if err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
}
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
// at the requested granularity, oldest-first. Callers without the
// account-wide usage grant get their own rows aggregated instead of a
// denial, so the dashboard serves "my usage" from the same endpoint.
// at the requested granularity, oldest-first.
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
filter, err := m.scopeFilterToCaller(ctx, accountID, userID, modules.AgentNetworkUsage, filter)
if err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
return nil, err
}
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
@@ -990,25 +975,6 @@ func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID st
return types.AggregateUsageByGranularity(rows, granularity), nil
}
// scopeFilterToCaller applies the account-wide read gate for module and,
// when the caller lacks the grant, pins the filter to the caller instead
// of denying: their own user id replaces any requested one and group
// filters are dropped. A caller may always see their own rows — strictly
// tighter than any role gate — which is what lets every authenticated
// user read their usage and requests through the regular endpoints.
// Validation errors (not denials) still fail closed.
func (m *managerImpl) scopeFilterToCaller(ctx context.Context, accountID, userID string, module modules.Module, filter types.AgentNetworkAccessLogFilter) (types.AgentNetworkAccessLogFilter, error) {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, operations.Read)
if err != nil {
return filter, status.NewPermissionValidationError(err)
}
if !ok {
filter.UserID = &userID
filter.GroupIDs = nil
}
return filter, nil
}
// StartAccessLogCleanup launches a background sweep that periodically deletes
// each account's agent-network access-log rows older than that account's
// AccessLogRetentionDays. Usage records are never swept. A non-positive

View File

@@ -1,239 +0,0 @@
package agentnetwork
import (
"context"
"fmt"
"sort"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/store"
)
// GetSetupForUser returns the Agent Network setup the calling user's
// groups authorize. It deliberately performs no role permission check:
// the result is scoped to the caller's own groups, which is strictly
// tighter than any role gate, so every authenticated user (any role) may
// read it. Peers and users carry the same groups, so the answer matches
// what the proxy enforces for the caller's machines at request time.
func (m *managerImpl) GetSetupForUser(ctx context.Context, accountID, userID string) (*types.EffectiveSetup, error) {
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return m.effectiveSetupForGroups(ctx, accountID, user.AutoGroups)
}
// effectiveSetupForGroups computes the effective Agent Network setup for
// a set of caller groups: the account endpoint plus, per authorized
// provider, the effective model set. It mirrors what the proxy enforces
// at request time — the policy filter matches filterApplicablePolicies,
// the model logic matches policyPermitsModel, and orphan providers
// (enabled but referenced by no applicable policy) are omitted just like
// the router synthesizer omits them — so the answer never advertises
// anything the proxy would refuse.
//
// Every "nothing available" shape returns Configured=false rather than
// an error, and "account not set up" is indistinguishable from "caller
// has no access" by design: the response must not leak what exists for
// others.
func (m *managerImpl) effectiveSetupForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.EffectiveSetup, error) {
notConfigured := &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
switch {
case err == nil:
case isNotFound(err):
return notConfigured, nil
default:
return nil, fmt.Errorf("get agent network settings: %w", err)
}
if settings.Endpoint() == "" {
return notConfigured, nil
}
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, fmt.Errorf("list account policies: %w", err)
}
applicable := filterPoliciesByGroups(policies, groupIDs)
if len(applicable) == 0 {
return notConfigured, nil
}
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, fmt.Errorf("list account providers: %w", err)
}
var guardrailsByID map[string]*types.Guardrail
if anyPolicyHasGuardrails(applicable) {
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
if err != nil {
return nil, err
}
}
authorized := make([]*types.Provider, 0, len(providers))
for _, p := range providers {
if p == nil || !p.Enabled {
continue
}
if len(policiesForProvider(applicable, p.ID)) == 0 {
continue
}
authorized = append(authorized, p)
}
if len(authorized) == 0 {
return notConfigured, nil
}
// created_at order, ID tiebreak — same deterministic order the router
// synthesizer presents.
sort.SliceStable(authorized, func(i, j int) bool {
if !authorized[i].CreatedAt.Equal(authorized[j].CreatedAt) {
return authorized[i].CreatedAt.Before(authorized[j].CreatedAt)
}
return authorized[i].ID < authorized[j].ID
})
out := &types.EffectiveSetup{
Configured: true,
Endpoint: "https://" + settings.Endpoint(),
Providers: make([]types.EffectiveProvider, 0, len(authorized)),
}
for _, p := range authorized {
allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
flavor := ""
if entry, ok := catalog.Lookup(p.ProviderID); ok {
flavor = entry.ParserID
}
out.Providers = append(out.Providers, types.EffectiveProvider{
Name: p.Name,
CatalogID: p.ProviderID,
APIFlavor: flavor,
AllModelsAllowed: allAllowed,
Models: models,
})
}
return out, nil
}
// filterPoliciesByGroups returns the enabled policies whose SourceGroups
// intersect the caller's groups. Same group matching as
// filterApplicablePolicies, without the per-provider filter — the setup
// answer spans every provider the caller can reach.
func filterPoliciesByGroups(policies []*types.Policy, groupIDs []string) []*types.Policy {
groupSet := make(map[string]struct{}, len(groupIDs))
for _, g := range groupIDs {
if g != "" {
groupSet[g] = struct{}{}
}
}
out := make([]*types.Policy, 0, len(policies))
for _, p := range policies {
if p == nil || !p.Enabled {
continue
}
if !anyGroupMatches(p.SourceGroups, groupSet) {
continue
}
out = append(out, p)
}
return out
}
// policiesForProvider returns the subset of policies targeting the
// provider, order preserved.
func policiesForProvider(policies []*types.Policy, providerID string) []*types.Policy {
out := make([]*types.Policy, 0, len(policies))
for _, p := range policies {
if sliceContains(p.DestinationProviderIDs, providerID) {
out = append(out, p)
}
}
return out
}
// effectiveModelsForProvider derives the caller's effective model set for
// one provider from the applicable policies that target it, mirroring
// policyPermitsModel: a policy with no allowlist-enabled guardrail is
// unrestricted, and one unrestricted policy makes the whole provider
// unrestricted (the proxy would admit any model through it). Otherwise
// the union of the policies' allowlists applies, intersected with the
// provider's declared models when the operator declared any — the router
// only claims declared models, so an allowlisted-but-undeclared model is
// unreachable and must not be advertised. With no declared models the
// router claims every model, so the allowlist union stands alone.
func effectiveModelsForProvider(provider *types.Provider, policies []*types.Policy, guardrailsByID map[string]*types.Guardrail) (bool, []string) {
restricted := true
union := make([]string, 0)
seen := make(map[string]struct{})
for _, p := range policies {
policyRestricted := false
for _, gID := range p.GuardrailIDs {
g, ok := guardrailsByID[gID]
if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled {
continue
}
policyRestricted = true
for _, model := range g.Checks.ModelAllowlist.Models {
key := normaliseModelID(model)
if key == "" {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
union = append(union, key)
}
}
if !policyRestricted {
restricted = false
}
}
declared := declaredModelIDs(provider)
if !restricted {
return true, declared
}
if len(provider.Models) == 0 {
// No operator declaration: the router claims every model, so the
// allowlist union is the effective set as-is.
return false, union
}
out := make([]string, 0, len(declared))
for _, id := range declared {
if _, ok := seen[normaliseModelID(id)]; ok {
out = append(out, id)
}
}
return false, out
}
// declaredModelIDs returns the models a provider exposes: the operator's
// curated list when present, otherwise the catalog entry's models (an
// empty operator list means "all catalog models"). Gateway/custom catalog
// entries declare no models, so the result may be empty.
func declaredModelIDs(provider *types.Provider) []string {
if ids := providerModelIDs(provider); len(ids) > 0 {
return ids
}
entry, ok := catalog.Lookup(provider.ProviderID)
if !ok {
return []string{}
}
out := make([]string, 0, len(entry.Models))
for _, m := range entry.Models {
if m.ID != "" {
out = append(out, m.ID)
}
}
return out
}
// GetSetupForUser on the mock manager reports "not configured" so tests
// that don't care about setup still compile.
func (*mockManager) GetSetupForUser(_ context.Context, _, _ string) (*types.EffectiveSetup, error) {
return &types.EffectiveSetup{Providers: []types.EffectiveProvider{}}, nil
}

View File

@@ -1,324 +0,0 @@
package agentnetwork
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
)
// These tests drive the effective-setup computation through the real
// sqlite store, mirroring the policyselect realstore suite: assert on
// observable answers (configured / providers / models), not on which
// store methods get called. The computation must agree with what the
// proxy enforces — policy filtering matches filterApplicablePolicies,
// model logic matches policyPermitsModel, and orphan providers are
// omitted like the router synthesizer omits them.
func newSetupTestMgr(t *testing.T) (*managerImpl, store.Store) {
t.Helper()
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
t.Cleanup(cleanup)
return &managerImpl{store: s}, s
}
// newSetupTestGuardrail returns an allowlist-enabled guardrail.
func newSetupTestGuardrail(id string, models ...string) *types.Guardrail {
return &types.Guardrail{
ID: id,
AccountID: testAccountID,
Name: "allowlist " + id,
Checks: types.GuardrailChecks{
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models},
},
}
}
func TestEffectiveSetup_RealStore_NoSettingsRow(t *testing.T) {
mgr, _ := newSetupTestMgr(t)
setup, err := mgr.effectiveSetupForGroups(context.Background(), testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.False(t, setup.Configured, "account without settings must read as not configured")
assert.Empty(t, setup.Endpoint)
assert.Empty(t, setup.Providers)
}
func TestEffectiveSetup_RealStore_NoApplicablePolicy(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-other"})
require.NoError(t, err)
assert.False(t, setup.Configured, "caller outside every policy's source groups must read as not configured")
assert.Empty(t, setup.Endpoint, "no-access answer must not leak the endpoint")
assert.Empty(t, setup.Providers)
}
func TestEffectiveSetup_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.True(t, setup.Configured)
assert.Equal(t, "https://"+testEndpoint, setup.Endpoint)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.Equal(t, "OpenAI", p.Name)
assert.Equal(t, "openai_api", p.CatalogID)
assert.Equal(t, "openai", p.APIFlavor)
assert.True(t, p.AllModelsAllowed, "policy without allowlist guardrail is unrestricted")
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "declared models listed as a courtesy")
}
func TestEffectiveSetup_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}}
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
// Allowlist admits gpt-5.4 (declared, odd casing/spacing) and gpt-4.1
// (NOT declared — the router would never route it, so it must not be
// advertised).
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", " GPT-5.4 ", "gpt-4.1")))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.False(t, p.AllModelsAllowed)
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "allowlist ∩ declared, in declared order and casing")
}
func TestEffectiveSetup_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
restricted := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, restricted))
open := newSynthTestPolicy(provider.ID, "grp-eng", "")
open.ID = "pol-2"
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, open))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
assert.True(t, setup.Providers[0].AllModelsAllowed,
"one applicable policy without an allowlist makes the provider unrestricted — the proxy would admit any model through it")
}
func TestEffectiveSetup_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}, {ID: "o4-mini"}}
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-2", "gpt-4o")))
p1 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p1))
p2 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-2")
p2.ID = "pol-2"
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p2))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.False(t, p.AllModelsAllowed)
assert.ElementsMatch(t, []string{"gpt-5.4", "gpt-4o"}, p.Models, "union of allowlists across applicable policies")
}
func TestEffectiveSetup_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
// Orphan: enabled but referenced by no policy.
orphan := newSynthTestProvider()
orphan.ID = "prov-orphan"
require.NoError(t, s.SaveAgentNetworkProvider(ctx, orphan))
// Disabled but referenced by an applicable policy.
disabled := newSynthTestProvider()
disabled.ID = "prov-disabled"
disabled.Enabled = false
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(disabled.ID, "grp-eng", "")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.False(t, setup.Configured, "neither an orphan nor a disabled provider is reachable, so nothing is configured for the caller")
assert.Empty(t, setup.Providers)
}
func TestEffectiveSetup_RealStore_DisabledPolicyIgnored(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
policy.Enabled = false
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
assert.False(t, setup.Configured)
}
func TestEffectiveSetup_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
// Gateway-style provider: no declared models — the router claims every
// model, so the allowlist union is the effective set on its own.
provider := newSynthTestProvider()
provider.ProviderID = "litellm_proxy"
provider.Name = "LiteLLM"
provider.Models = nil
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "claude-sonnet-4-5")))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 1)
p := setup.Providers[0]
assert.False(t, p.AllModelsAllowed)
assert.Equal(t, []string{"claude-sonnet-4-5"}, p.Models)
}
func TestEffectiveSetup_RealStore_ProvidersInCreatedAtOrder(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
newer := newSynthTestProvider()
newer.ID = "prov-newer"
newer.Name = "Newer"
newer.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, s.SaveAgentNetworkProvider(ctx, newer))
older := newSynthTestProvider()
older.ID = "prov-older"
older.Name = "Older"
older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, s.SaveAgentNetworkProvider(ctx, older))
policy := newSynthTestPolicy(newer.ID, "grp-eng", "")
policy.DestinationProviderIDs = []string{newer.ID, older.ID}
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
setup, err := mgr.effectiveSetupForGroups(ctx, testAccountID, []string{"grp-eng"})
require.NoError(t, err)
require.Len(t, setup.Providers, 2)
assert.Equal(t, "Older", setup.Providers[0].Name)
assert.Equal(t, "Newer", setup.Providers[1].Name)
}
// TestGetSetupForUser_RealStore pins the self-service entry point: the
// user's group memberships (AutoGroups — the same groups the user's peers
// carry) scope the answer, and users outside every policy get the
// indistinguishable not-configured shape.
func TestGetSetupForUser_RealStore(t *testing.T) {
mgr, s := newSetupTestMgr(t)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
// users.account_id is a foreign key into accounts, enforced on
// MySQL/Postgres, so the account row must exist before its users.
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-other"},
}))
setupIn, err := mgr.GetSetupForUser(ctx, testAccountID, "user-in")
require.NoError(t, err)
assert.True(t, setupIn.Configured)
require.Len(t, setupIn.Providers, 1)
setupOut, err := mgr.GetSetupForUser(ctx, testAccountID, "user-out")
require.NoError(t, err)
assert.False(t, setupOut.Configured, "user outside the policy's source groups gets the not-configured answer")
}
// TestGetUsageOverview_RealStore_SelfScoped pins the self-scope fallback:
// a caller without the account-wide usage grant gets the same aggregation
// the admin overview serves, but only ever their own rows — a user_id
// filter for someone else must be overridden, not honored, and never
// denied. A caller holding the grant keeps the account-wide view.
func TestGetUsageOverview_RealStore_SelfScoped(t *testing.T) {
mgr, s := newSetupTestMgr(t)
mgr.permissionsManager = permissions.NewManager(s)
ctx := context.Background()
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser,
}))
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
}))
own1 := newIngestTestEntry()
own1.ID, own1.UserId = "log-own-1", "user-a"
own2 := newIngestTestEntry()
own2.ID, own2.UserId = "log-own-2", "user-a"
other := newIngestTestEntry()
other.ID, other.UserId = "log-other", "user-b"
for _, e := range []*accesslogs.AccessLogEntry{own1, own2, other} {
require.NoError(t, IngestAccessLog(ctx, s, e))
}
otherID := "user-b"
filter := types.AgentNetworkAccessLogFilter{UserID: &otherID}
buckets, err := mgr.GetUsageOverview(ctx, testAccountID, "user-a", filter, types.ParseUsageGranularity(""))
require.NoError(t, err)
require.Len(t, buckets, 1, "same-day rows aggregate into one daily bucket")
assert.Equal(t, int64(200), buckets[0].InputTokens, "only the caller's two rows count — the foreign user_id filter is overridden")
assert.Equal(t, int64(100), buckets[0].OutputTokens)
adminBuckets, err := mgr.GetUsageOverview(ctx, testAccountID, "admin", types.AgentNetworkAccessLogFilter{}, types.ParseUsageGranularity(""))
require.NoError(t, err)
require.Len(t, adminBuckets, 1)
assert.Equal(t, int64(300), adminBuckets[0].InputTokens, "the account-wide grant keeps the unscoped view")
}

View File

@@ -1,40 +0,0 @@
package types
// EffectiveSetup is the caller-scoped answer to "what may this caller
// use on the Agent Network?" — the account's proxy endpoint plus the
// providers and models the caller's groups authorize. It intentionally
// carries display metadata only: no keys, no upstream URLs, no policy or
// guardrail structure, and no hint of providers the caller cannot reach.
type EffectiveSetup struct {
// Configured is false when the account has no Agent Network set up or
// when nothing is authorized for the caller's groups — the two cases
// are deliberately indistinguishable so the response leaks nothing
// about what exists for others.
Configured bool
// Endpoint is the account's proxy base URL
// ("https://<subdomain>.<cluster>"), reachable over the NetBird tunnel
// only. Empty when Configured is false.
Endpoint string
// Providers lists the providers at least one applicable policy
// authorizes for the caller, in the account's created_at order.
Providers []EffectiveProvider
}
// EffectiveProvider is one authorized provider in an EffectiveSetup.
type EffectiveProvider struct {
// Name is the operator-assigned label, e.g. "Bedrock prod".
Name string
// CatalogID names the catalog entry, e.g. "anthropic_api".
CatalogID string
// APIFlavor is the request-body shape the provider speaks — the
// catalog entry's parser id ("anthropic", "openai"); empty when the
// proxy dispatches the provider by URL path instead.
APIFlavor string
// AllModelsAllowed is true when no model allowlist restricts this
// provider for the caller. Models then lists the declared/catalog
// models as a courtesy (possibly none for gateway-style providers).
AllModelsAllowed bool
// Models is the effective model allowlist for the caller, or the
// declared/catalog models when AllModelsAllowed is true.
Models []string
}

View File

@@ -1,140 +0,0 @@
package permissions
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/permissions/roles"
"github.com/netbirdio/netbird/management/server/types"
)
var allOps = []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
// TestAgentNetworkAdminRole pins the delegated-admin contract: full control
// over the whole agent_network area (parent grant cascades to every
// submodule), read-only on the account objects needed to build policies,
// and nothing else in the account.
func TestAgentNetworkAdminRole(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
role, ok := roles.RolesMap[types.UserRoleAgentNetworkAdmin]
require.True(t, ok, "agent_network_admin must exist in RolesMap")
agentNetworkModules := []modules.Module{
modules.AgentNetwork,
modules.AgentNetworkProviders,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkUsage,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
}
for _, m := range agentNetworkModules {
for _, op := range allOps {
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"agent_network_admin must have %s on %s", op, m)
}
}
// Settings read rides along because GET /api/accounts (which the
// dashboard needs to boot) validates it, like network_admin.
for _, m := range []modules.Module{modules.Users, modules.Groups, modules.Peers, modules.Accounts, modules.Settings} {
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
"agent_network_admin must read %s to build policies and load the dashboard", m)
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"agent_network_admin must not have %s on %s", op, m)
}
}
for _, m := range []modules.Module{modules.Networks, modules.Dns, modules.SetupKeys, modules.Routes} {
for _, op := range allOps {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"agent_network_admin must not have %s on %s", op, m)
}
}
}
// TestUsageViewerRole pins the least-privilege cost role: read on the
// aggregated usage overview plus read-only on the resources its filters
// and display columns resolve against (users, groups, peers, the provider
// list) — no policies, no request-level logs (which can contain captured
// prompts), nothing else in the account.
func TestUsageViewerRole(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
role, ok := roles.RolesMap[types.UserRoleUsageViewer]
require.True(t, ok, "usage_viewer must exist in RolesMap")
readOnly := []modules.Module{
modules.AgentNetworkUsage,
modules.AgentNetworkProviders,
modules.Users,
modules.Groups,
modules.Peers,
}
for _, m := range readOnly {
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read),
"usage_viewer must read %s for the usage view and its filters", m)
for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"usage_viewer must not have %s on %s", op, m)
}
}
denied := []modules.Module{
modules.AgentNetwork,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
modules.Networks,
modules.SetupKeys,
}
for _, m := range denied {
for _, op := range allOps {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"usage_viewer must not have %s on %s", op, m)
}
}
}
// TestBillingAdminRoleResolves pins that billing_admin has a proper entry
// in the permission map. Its plan/seat/invoice permissions are enforced
// outside this map; management-side it carries the regular User baseline
// instead of failing role resolution.
func TestBillingAdminRoleResolves(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
role, ok := roles.RolesMap[types.UserRoleBillingAdmin]
require.True(t, ok, "billing_admin must exist in RolesMap")
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleBillingAdmin)
require.NoError(t, err, "billing_admin role must resolve")
require.NotEmpty(t, permissions)
for _, m := range []modules.Module{modules.AgentNetwork, modules.Networks, modules.Users, modules.Peers} {
for _, op := range allOps {
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op),
"billing_admin must not have %s on %s", op, m)
}
}
}
// TestNewRolesParse pins the API role strings, which are permanent once
// released.
func TestNewRolesParse(t *testing.T) {
assert.Equal(t, types.UserRoleAgentNetworkAdmin, types.StrRoleToUserRole("agent_network_admin"))
assert.Equal(t, types.UserRoleUsageViewer, types.StrRoleToUserRole("usage_viewer"))
assert.Equal(t, types.UserRoleBillingAdmin, types.StrRoleToUserRole("billing_admin"))
}

View File

@@ -1,62 +0,0 @@
package roles
import (
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/types"
)
// AgentNetworkAdmin is the delegated administrator for the Agent Network
// area: full control over providers, policies, guardrails, budgets, usage,
// logs, and its settings, plus read-only visibility into the account
// objects needed to build policies (users, groups, peers) and the account
// settings/meta read the dashboard needs to boot (GET /api/accounts
// validates Settings read, same as network_admin). Nothing else in the
// account is visible.
var AgentNetworkAdmin = RolePermissions{
Role: types.UserRoleAgentNetworkAdmin,
AutoAllowNew: map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
Permissions: Permissions{
modules.AgentNetwork: {
operations.Read: true,
operations.Create: true,
operations.Update: true,
operations.Delete: true,
},
modules.Users: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Groups: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Peers: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Accounts: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Settings: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
},
}

View File

@@ -1,20 +0,0 @@
package roles
import (
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/types"
)
// BillingAdmin manages plans, seats, and invoices, which are enforced
// outside this permission map (NetBird Cloud). Management-side it carries
// the regular User baseline; the explicit entry keeps role resolution from
// failing with a role-not-found error.
var BillingAdmin = RolePermissions{
Role: types.UserRoleBillingAdmin,
AutoAllowNew: map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
}

View File

@@ -15,12 +15,9 @@ type RolePermissions struct {
type Permissions map[modules.Module]map[operations.Operation]bool
var RolesMap = map[types.UserRole]RolePermissions{
types.UserRoleOwner: Owner,
types.UserRoleAdmin: Admin,
types.UserRoleUser: User,
types.UserRoleAuditor: Auditor,
types.UserRoleNetworkAdmin: NetworkAdmin,
types.UserRoleAgentNetworkAdmin: AgentNetworkAdmin,
types.UserRoleUsageViewer: UsageViewer,
types.UserRoleBillingAdmin: BillingAdmin,
types.UserRoleOwner: Owner,
types.UserRoleAdmin: Admin,
types.UserRoleUser: User,
types.UserRoleAuditor: Auditor,
types.UserRoleNetworkAdmin: NetworkAdmin,
}

View File

@@ -1,56 +0,0 @@
package roles
import (
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/types"
)
// UsageViewer is the regular User baseline plus read access to the
// aggregated Agent Network usage and cost overview, and read-only access
// to the resources the usage filters and display columns resolve against:
// users and groups (identity filters and name resolution), peers (agent
// principals in the caller column), and the provider list (provider and
// model filter options). It sees no policies and no request-level access
// logs (which can contain captured prompts).
var UsageViewer = RolePermissions{
Role: types.UserRoleUsageViewer,
AutoAllowNew: map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
Permissions: Permissions{
modules.AgentNetworkUsage: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.AgentNetworkProviders: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Users: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Groups: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
modules.Peers: {
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
},
},
}

View File

@@ -11,15 +11,13 @@ import (
)
const (
UserRoleOwner UserRole = "owner"
UserRoleAdmin UserRole = "admin"
UserRoleUser UserRole = "user"
UserRoleUnknown UserRole = "unknown"
UserRoleBillingAdmin UserRole = "billing_admin"
UserRoleAuditor UserRole = "auditor"
UserRoleNetworkAdmin UserRole = "network_admin"
UserRoleAgentNetworkAdmin UserRole = "agent_network_admin"
UserRoleUsageViewer UserRole = "usage_viewer"
UserRoleOwner UserRole = "owner"
UserRoleAdmin UserRole = "admin"
UserRoleUser UserRole = "user"
UserRoleUnknown UserRole = "unknown"
UserRoleBillingAdmin UserRole = "billing_admin"
UserRoleAuditor UserRole = "auditor"
UserRoleNetworkAdmin UserRole = "network_admin"
UserStatusActive UserStatus = "active"
UserStatusDisabled UserStatus = "disabled"
@@ -44,10 +42,6 @@ func StrRoleToUserRole(strRole string) UserRole {
return UserRoleAuditor
case "network_admin":
return UserRoleNetworkAdmin
case "agent_network_admin":
return UserRoleAgentNetworkAdmin
case "usage_viewer":
return UserRoleUsageViewer
default:
return UserRoleUnknown
}
@@ -146,7 +140,7 @@ func (u *User) IsRegularUser() bool {
// IsRestrictable checks whether a user is in a restrictable role.
func (u *User) IsRestrictable() bool {
return u.Role == UserRoleUser || u.Role == UserRoleBillingAdmin || u.Role == UserRoleUsageViewer
return u.Role == UserRoleUser || u.Role == UserRoleBillingAdmin
}
// ToUserInfo converts a User object to a UserInfo object.

View File

@@ -12,15 +12,19 @@ import (
// bedrockVendorNamespaces are the vendor segments a Bedrock model id is
// published under. They identify the geography in front of a cross-region
// inference profile without knowing the geography: in
// inference profile without enumerating geographies: in
// "eu.anthropic.claude-...", what makes "eu" a geography is that "anthropic"
// follows it.
//
// A vendor missing from here is not fatal — bedrockGeographies covers the
// same id from the other side — but it is one of the two ways an id can go
// unrecognised, and the list needs a new entry whenever AWS onboards a
// vendor. A live listing found "global.xai.grok-4.6" days after this was
// first written.
// Listing geographies instead is what this replaced, and it aged badly — the
// list held us, eu, apac and global, so every profile issued under jp, au, ca,
// sa or us-gov carried its prefix into the pricing key, matched no catalog
// entry, and reported the model unpriced.
//
// A vendor missing from this map fails safe: its id keeps the geography, which
// is exactly the behaviour of the list this replaced. Over-stripping is the
// dangerous direction, because the result also decides which route may claim a
// model.
var bedrockVendorNamespaces = map[string]struct{}{
"ai21": {},
"amazon": {},
@@ -35,62 +39,29 @@ var bedrockVendorNamespaces = map[string]struct{}{
"stability": {},
"twelvelabs": {},
"writer": {},
"xai": {},
}
// bedrockGeographies are the geography segments AWS issues cross-region
// inference profiles under. They recognise a profile whose vendor we have
// never seen, which is the case bedrockVendorNamespaces alone gets wrong:
// "global.xai.grok-4.6" is a geography and a model whether or not "xai" is
// a name we know.
//
// Neither list is sufficient alone. A geography list on its own is what this
// file started with, and it aged badly — it held us, eu, apac and global, so
// every profile issued under jp, au, ca, sa or us-gov carried its prefix into
// the pricing key, matched no catalog entry, and reported the model unpriced.
// A vendor list on its own misses a new vendor under a known geography.
// Together, an id has to be new on both axes at once to go unrecognised.
var bedrockGeographies = map[string]struct{}{
"apac": {},
"au": {},
"ca": {},
"eu": {},
"global": {},
"jp": {},
"sa": {},
"us": {},
"us-gov": {},
}
// stripBedrockGeography removes the cross-region inference-profile geography
// from a Bedrock model id, leaving the "<vendor>.<model>" form the catalog and
// the pricing table key on.
//
// A leading segment counts as a geography when it is one we know, or when a
// known vendor follows it. Either alone is enough: the id has to be new on
// both axes before its geography survives.
//
// The segment has to be followed by two more, so "amazon.nova-pro" stays a
// vendor and a model rather than becoming a geography and a model — cutting
// its first segment would strip the vendor away. Over-stripping is the
// dangerous direction, because the result also decides which route may claim
// a model.
// A leading segment counts as a geography only when a known vendor follows it.
// "amazon.nova-pro" is a vendor and a model, not a geography and a model, and
// cutting its first segment would strip the vendor away.
func stripBedrockGeography(modelID string) string {
geo, rest, found := strings.Cut(modelID, ".")
if !found || geo == "" {
dot := strings.IndexByte(modelID, '.')
if dot <= 0 {
return modelID
}
rest := modelID[dot+1:]
vendor, _, found := strings.Cut(rest, ".")
if !found {
return modelID
}
if _, ok := bedrockGeographies[geo]; ok {
return rest
if _, ok := bedrockVendorNamespaces[vendor]; !ok {
return modelID
}
if _, ok := bedrockVendorNamespaces[vendor]; ok {
return rest
}
return modelID
return rest
}
// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]"

View File

@@ -81,13 +81,11 @@ func TestNormalizeBedrockModel_GeographiesBeyondTheOriginalFour(t *testing.T) {
// hand the id to whichever route claims the bare model name.
func TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography(t *testing.T) {
cases := map[string]string{
"amazon.nova-pro-v1:0": "amazon.nova-pro",
"anthropic.claude-sonnet-5-v1:0": "anthropic.claude-sonnet-5",
"meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct",
"cohere.command-r-plus-v1:0": "cohere.command-r-plus",
// Unknown on both axes: neither the leading segment nor the one
// after it is a name we hold, so the id is left exactly as it came.
"xx.unknownvendor.some-model-v1:0": "xx.unknownvendor.some-model",
"amazon.nova-pro-v1:0": "amazon.nova-pro",
"anthropic.claude-sonnet-5-v1:0": "anthropic.claude-sonnet-5",
"meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct",
"cohere.command-r-plus-v1:0": "cohere.command-r-plus",
"eu.unknownvendor.some-model-v1:0": "eu.unknownvendor.some-model",
"Qwen/Qwen2.5-0.5B-Instruct": "Qwen/Qwen2.5-0.5B-Instruct",
}
for in, want := range cases {
@@ -96,25 +94,3 @@ func TestNormalizeBedrockModel_KeepsAVendorItCannotMistakeForAGeography(t *testi
})
}
}
// TestNormalizeBedrockModel_RecognisesAnIdNewOnOneAxis covers what a live
// eu-central-1 listing returned days after the vendor list was written:
// "global.xai.grok-4.6", a vendor the list did not hold. Anchoring only on the
// vendor left the geography in the key, so the id matched no catalog entry and
// the model metered at zero. Each id below is unfamiliar on one axis and
// recognised through the other.
func TestNormalizeBedrockModel_RecognisesAnIdNewOnOneAxis(t *testing.T) {
cases := map[string]string{
// Known geography, vendor we had never seen (the live case).
"global.xai.grok-4.6": "xai.grok-4.6",
"eu.xai.grok-4.6": "xai.grok-4.6",
// Known vendor, geography outside the list.
"il.anthropic.claude-sonnet-5-20260514-v1:0": "anthropic.claude-sonnet-5",
"mx.amazon.nova-2-lite-v1:0": "amazon.nova-2-lite",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
require.Equal(t, want, NormalizeBedrockModel(in))
})
}
}

View File

@@ -5807,57 +5807,6 @@ components:
required:
- name
- checks
AgentNetworkMeSetup:
type: object
description: The caller-scoped Agent Network connection info backing the "My Agent Network" self-service view. Available to every authenticated user; the answer is computed from the caller's own groups and carries display metadata only.
properties:
configured:
type: boolean
description: False when the account has no Agent Network set up or the caller's groups authorize none of it. The two cases are deliberately indistinguishable.
endpoint:
type: string
description: The account's Agent Network base URL, reachable over the NetBird tunnel only. Empty when configured is false.
example: https://calm-otter.proxy.example.com
providers:
type: array
description: The providers at least one of the caller's policies authorizes, in creation order.
items:
$ref: '#/components/schemas/AgentNetworkMeProvider'
required:
- configured
- endpoint
- providers
AgentNetworkMeProvider:
type: object
description: One provider the caller may use, reduced to what a local tool needs for configuration.
properties:
name:
type: string
description: Operator-assigned provider label.
example: Bedrock prod
catalog_id:
type: string
description: Catalog entry id naming the provider type.
example: bedrock_api
api_flavor:
type: string
description: Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead.
example: anthropic
all_models_allowed:
type: boolean
description: True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy.
models:
type: array
description: The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true).
items:
type: string
example: [ "anthropic.claude-sonnet-4-5" ]
required:
- name
- catalog_id
- api_flavor
- all_models_allowed
- models
AgentNetworkConsumption:
type: object
description: One per-(dimension, window) consumption counter row. The proxy ticks one row per dimension on every served LLM request; the dashboard reads this listing to surface live counter growth.
@@ -13522,7 +13471,7 @@ paths:
/api/agent-network/access-logs:
get:
summary: List Agent Network access logs
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access log entries. Available only when the account has log collection enabled; otherwise entries are not retained. Callers without the account-wide grant are not denied - the response is scoped to their own requests (any user_id or group_id filter is overridden).
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access log entries. Available only when the account has log collection enabled; otherwise entries are not retained.
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13637,7 +13586,7 @@ paths:
/api/agent-network/access-log-sessions:
get:
summary: List Agent Network access logs grouped by session
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled. Callers without the account-wide grant are not denied - the response is scoped to their own requests (any user_id or group_id filter is overridden).
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled.
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13752,7 +13701,7 @@ paths:
/api/agent-network/usage/overview:
get:
summary: Agent Network usage overview
description: Returns agent-network token and cost usage aggregated into time buckets, server-side filtered. Usage is always collected (independent of log collection). Callers without the account-wide grant are not denied - the response is scoped to their own usage (any user_id or group_id filter is overridden).
description: Returns agent-network token and cost usage aggregated into time buckets, server-side filtered. Usage is always collected (independent of log collection).
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
@@ -13852,25 +13801,6 @@ paths:
"$ref": "#/components/responses/forbidden"
'500':
"$ref": "#/components/responses/internal_error"
/api/agent-network/me/setup:
get:
summary: Retrieve the caller's Agent Network setup
description: Returns everything the caller needs to configure a local AI tool and nothing more - the account's Agent Network endpoint plus the providers and models the caller's own policies allow. Available to every authenticated user regardless of role; the response never contains provider credentials, policy or guardrail configuration, or providers the caller cannot reach.
tags: [ Agent Network ]
security:
- BearerAuth: [ ]
- TokenAuth: [ ]
responses:
'200':
description: The caller-scoped Agent Network connection info
content:
application/json:
schema:
$ref: '#/components/schemas/AgentNetworkMeSetup'
'401':
"$ref": "#/components/responses/requires_authentication"
'500':
"$ref": "#/components/responses/internal_error"
/api/agent-network/settings:
get:
summary: Retrieve Agent Network settings

View File

@@ -2194,36 +2194,6 @@ type AgentNetworkGuardrailRequest struct {
Name string `json:"name"`
}
// AgentNetworkMeProvider One provider the caller may use, reduced to what a local tool needs for configuration.
type AgentNetworkMeProvider struct {
// AllModelsAllowed True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy.
AllModelsAllowed bool `json:"all_models_allowed"`
// ApiFlavor Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead.
ApiFlavor string `json:"api_flavor"`
// CatalogId Catalog entry id naming the provider type.
CatalogId string `json:"catalog_id"`
// Models The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true).
Models []string `json:"models"`
// Name Operator-assigned provider label.
Name string `json:"name"`
}
// AgentNetworkMeSetup The caller-scoped Agent Network connection info backing the "My Agent Network" self-service view. Available to every authenticated user; the answer is computed from the caller's own groups and carries display metadata only.
type AgentNetworkMeSetup struct {
// Configured False when the account has no Agent Network set up or the caller's groups authorize none of it. The two cases are deliberately indistinguishable.
Configured bool `json:"configured"`
// Endpoint The account's Agent Network base URL, reachable over the NetBird tunnel only. Empty when configured is false.
Endpoint string `json:"endpoint"`
// Providers The providers at least one of the caller's policies authorizes, in creation order.
Providers []AgentNetworkMeProvider `json:"providers"`
}
// AgentNetworkModelDiscoveryRequest defines model for AgentNetworkModelDiscoveryRequest.
type AgentNetworkModelDiscoveryRequest struct {
// ApiKey Credential to query the vendor with, for a provider that has not been saved yet. Mutually exclusive with provider_id.