diff --git a/e2e/tests/agent-network-agentgateway-provider.spec.ts b/e2e/tests/agent-network-agentgateway-provider.spec.ts new file mode 100644 index 000000000..8d72106be --- /dev/null +++ b/e2e/tests/agent-network-agentgateway-provider.spec.ts @@ -0,0 +1,148 @@ +/** + * Agent Network agentgateway provider spec. + * + * Exercises the catalog-driven provider flow against a management build that + * includes the agentgateway catalog entry. Older builds skip the test. + */ +import { + deleteAgentNetworkProvidersByPrefix, + listAgentNetworkCatalog, + supportsAgentNetworkSettingsBootstrap, +} from "../helpers/api"; +import { navigateTo } from "../helpers/auth"; +import { expect, test } from "../helpers/fixtures"; +import { generateRandomName } from "../helpers/utils"; + +const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network"; +const AGENTGATEWAY_CATALOG_ID = "agentgateway"; +const PROVIDER_PREFIX = "e2e-agentgateway-"; + +test.describe + .serial("Agent Network agentgateway provider @agent-network", () => { + test("connect agentgateway with its trusted identity mapping", async ({ + dashboardAsOwner: page, + }) => { + await page.addInitScript( + ([key, value]) => { + try { + window.localStorage.setItem(key as string, value as string); + } catch {} + }, + [AGENT_NETWORK_CONFIG_KEY, "enabled"], + ); + + const catalog = await listAgentNetworkCatalog(page); + test.skip( + !catalog.some((entry) => entry.id === AGENTGATEWAY_CATALOG_ID), + `management catalog has no ${AGENTGATEWAY_CATALOG_ID} entry`, + ); + test.skip( + !(await supportsAgentNetworkSettingsBootstrap(page)), + "management build does not support Agent Network settings bootstrap", + ); + + await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX); + try { + await navigateTo(page, "/agent-network/providers"); + + await page + .getByTestId("connect-agent-network-provider") + .first() + .click({ force: true }); + await page + .getByTestId("agent-network-provider-type") + .click({ force: true }); + await page + .getByTestId("select-dropdown-search") + .fill(AGENTGATEWAY_CATALOG_ID); + await page + .getByTestId("agent-network-provider-option-agentgateway") + .click({ force: true }); + + const upstreamURL = "https://agentgateway.e2e.example"; + await page + .getByTestId("agent-network-provider-upstream-url") + .fill(upstreamURL); + await page + .getByTestId("agent-network-provider-api-key") + .fill("e2e-agentgateway-virtual-key"); + + const providerName = generateRandomName(PROVIDER_PREFIX); + await page.getByTestId("agent-network-provider-name").fill(providerName); + + await page + .getByTestId("agent-network-provider-models-tab") + .click({ force: true }); + await expect( + page.getByTestId("agent-network-provider-models-help"), + ).toContainText("Empty = all catalog models"); + await page + .getByTestId("agent-network-provider-continue") + .click({ force: true }); + + await expect( + page.getByTestId("agent-network-provider-mappings-tab"), + ).toHaveAttribute("data-state", "active"); + await expect( + page.getByTestId("agent-network-provider-user-mapping"), + ).toContainText("x-netbird-user-id"); + await expect( + page.getByTestId("agent-network-provider-groups-mapping"), + ).toContainText("x-netbird-groups"); + await expect( + page.getByTestId("agent-network-provider-groups-guidance"), + ).toContainText("must not be used as an agentgateway authorization claim"); + + const createResponse = page.waitForResponse( + (response) => + response.url().includes("/agent-network/providers") && + response.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("agent-network-provider-submit").click({ + force: true, + }); + const created = await createResponse; + expect([200, 201]).toContain(created.status()); + + const body = created.request().postDataJSON(); + expect(body).toMatchObject({ + provider_id: AGENTGATEWAY_CATALOG_ID, + name: providerName, + upstream_url: upstreamURL, + models: [], + metadata_disabled: false, + }); + expect(body).not.toHaveProperty("identity_header_user_id"); + expect(body).not.toHaveProperty("identity_header_groups"); + + await expect(page.getByTestId(providerName)).toBeVisible(); + await expect( + page.getByTestId(`provider-models-${providerName}`), + ).toHaveText("All models"); + + await page.getByTestId(providerName).click({ force: true }); + await expect( + page.getByTestId("agent-network-provider-modal"), + ).toBeVisible(); + await expect( + page.getByTestId("agent-network-provider-upstream-url"), + ).toHaveValue(upstreamURL); + await page + .getByTestId("agent-network-provider-mappings-tab") + .click({ force: true }); + await expect( + page.getByTestId("agent-network-provider-user-mapping"), + ).toContainText("x-netbird-user-id"); + await expect( + page.getByTestId("agent-network-provider-groups-mapping"), + ).toContainText("x-netbird-groups"); + await page + .getByTestId("agent-network-provider-modal") + .getByTestId("modal-close") + .click({ force: true }); + } finally { + await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX); + } + }); +}); diff --git a/src/modules/agent-network/AIProviderModal.tsx b/src/modules/agent-network/AIProviderModal.tsx index 1d13c3ac1..14e1f004e 100644 --- a/src/modules/agent-network/AIProviderModal.tsx +++ b/src/modules/agent-network/AIProviderModal.tsx @@ -113,6 +113,8 @@ function upstreamUrlPlaceholder(providerId: AIProviderId): string { return "https://openrouter.ai/api/v1"; case "litellm_proxy": return "https://your-litellm-host"; + case "agentgateway": + return "https://your-agentgateway-proxy"; case "portkey": return "https://api.portkey.ai"; case "vllm": @@ -140,6 +142,8 @@ function upstreamUrlHelpText(providerId: AIProviderId): string { return "Vercel AI Gateway uses a fixed endpoint; only the API key varies by operator. Apps choose the upstream provider with the model prefix, e.g. openai/gpt-5.4 or anthropic/claude-opus-4.6."; case "openrouter": return "OpenRouter uses a fixed endpoint, openrouter.ai/api/v1; apps choose the upstream provider via the model prefix, e.g. anthropic/claude-* or openai/gpt-*."; + case "agentgateway": + return "The agentgateway proxy listener URL reachable from the NetBird proxy. Keep this listener private so requests cannot bypass NetBird's identity enforcement."; case "vllm": return "Your local vLLM server's OpenAI-compatible base URL."; default: @@ -274,6 +278,10 @@ export default function AIProviderModal({ // entry, so this never double-counts. const customizableIdentity = customizableHeaderPair || customizableJsonMetadata; + const fixedHeaderPair = + catalog?.identity_injection?.header_pair?.customizable === false + ? catalog.identity_injection.header_pair + : undefined; // Defaults shown as input placeholders. The first non-empty source // wins; HeaderPair vs JSONMetadata are exclusive so either branch // is empty when the other is set. @@ -292,20 +300,20 @@ export default function AIProviderModal({ const jsonMetadataHeader = catalog?.identity_injection?.json_metadata?.header ?? ""; - // showMappings reveals the Mappings tab for provider types whose - // downstream gateway keys identity off NetBird-stamped headers. - // For non-customizable shapes (LiteLLM, Portkey) the mapping is - // fixed in v1 — the tab is read-only. For customizable shapes - // (Bifrost) the operator picks the wire header names, so the tab - // renders editable inputs. + // The management catalog owns the general identity-injection contract. + // Bedrock retains its separate request-metadata mapping, and fixed HeaderPair + // providers without tailored guidance get the generic read-only view. const showMappings = - providerId === "litellm_proxy" || - providerId === "portkey" || - providerId === "bifrost" || - providerId === "cloudflare_ai_gateway" || - providerId === "vercel_ai_gateway" || - providerId === "openrouter" || - providerId === "bedrock_api"; + !!catalog?.identity_injection || providerId === "bedrock_api"; + const hasSpecializedFixedHeaderPairView = [ + "litellm_proxy", + "vercel_ai_gateway", + "openrouter", + "portkey", + "bedrock_api", + ].includes(providerId); + const showGenericFixedHeaderPair = + !!fixedHeaderPair && !hasSpecializedFixedHeaderPairView; // If the user flips provider type while viewing the Mappings tab and // the new type doesn't show mappings, snap back to the Provider tab @@ -546,6 +554,11 @@ export default function AIProviderModal({ label: p.name, searchValue: `${p.name} ${p.id}`, group: groupLabel[p.kind] ?? "Other", + renderItem: () => ( + + {p.name} + + ), icon: ({ size }: { size?: number }) => ( ), @@ -707,7 +720,10 @@ export default function AIProviderModal({ return ( (o ? null : handleClose())}> - + } title={isEdit ? "Edit Provider" : "Connect Provider"} @@ -725,7 +741,11 @@ export default function AIProviderModal({ Provider - + Models @@ -733,6 +753,7 @@ export default function AIProviderModal({ Mappings @@ -767,6 +788,7 @@ export default function AIProviderModal({ helpText={"AI provider and upstream URL to expose through NetBird."} > { const next = v as AIProviderId; @@ -820,6 +842,7 @@ export default function AIProviderModal({ /> setUpstreamUrl(e.target.value)} placeholder={upstreamUrlPlaceholder(providerId)} @@ -914,7 +937,9 @@ export default function AIProviderModal({ - Provider API key + {providerId === "agentgateway" + ? "Virtual API key" + : "Provider API key"} @@ -929,9 +954,14 @@ export default function AIProviderModal({ /> } - helpText={"The API key issued by the provider."} + helpText={ + providerId === "agentgateway" + ? "The raw virtual key configured for strict API-key authentication on agentgateway." + : "The API key issued by the provider." + } > @@ -983,6 +1015,7 @@ export default function AIProviderModal({ helpText={"Shown in the Agent Network table."} > setName(e.target.value)} placeholder={"e.g. OpenAI"} @@ -1189,6 +1222,84 @@ export default function AIProviderModal({ )} + {showMappings && showGenericFixedHeaderPair && ( + +
+ setMetadataDisabled(!v)} + label={ + <> + + Forward Identity Metadata + + } + helpText={ + "Stamp the trusted NetBird identity headers below onto upstream requests." + } + /> + +
+ + + NetBird removes caller-supplied values before adding the + authenticated identity shown below. The upstream listener + must remain reachable only through the NetBird proxy. + {providerId === "agentgateway" && ( + <> + {" "} + The virtual key authenticates NetBird but does not make + headers from another network path trustworthy. + + )} + +
+ +
+ {fixedHeaderPair?.end_user_id_header && ( + + )} + {fixedHeaderPair?.tags_header && ( + + )} +
+ + {providerId === "agentgateway" && ( +
+ + + x-netbird-groups + {" "} + contains sorted group display names for attribution. It + is not a delimiter-safe set of stable group IDs and must + not be used as an agentgateway authorization claim. + +
+ )} +
+
+ )} + {showMappings && providerId === "portkey" && (
@@ -1406,13 +1517,15 @@ export default function AIProviderModal({
- - Models exposed through this endpoint, with the per-1k - input/output prices used for cost tracking. Empty = all - catalog models allowed at catalog prices. Cache rates left - empty fall back to NetBird's defaults for the model; 0 - bills cached tokens at the input rate. - +
+ + Models exposed through this endpoint, with the per-1k + input/output prices used for cost tracking. Empty = all + catalog models allowed at catalog prices. Cache rates left + empty fall back to NetBird's defaults for the model; 0 + bills cached tokens at the input rate. + +
@@ -1556,6 +1669,7 @@ export default function AIProviderModal({ variant={"primary"} onClick={() => setTab("models")} disabled={!canContinueFromProvider} + data-testid={"agent-network-provider-continue"} > Continue @@ -1574,6 +1688,7 @@ export default function AIProviderModal({ variant={"primary"} onClick={() => setTab("mappings")} disabled={!canContinueFromProvider} + data-testid={"agent-network-provider-continue"} > Continue @@ -1582,6 +1697,7 @@ export default function AIProviderModal({ variant={"primary"} onClick={handleSubmit} disabled={!canContinueFromProvider} + data-testid={"agent-network-provider-submit"} > {isEdit ? ( "Save Changes" @@ -1604,6 +1720,7 @@ export default function AIProviderModal({ variant={"primary"} onClick={handleSubmit} disabled={!canContinueFromProvider} + data-testid={"agent-network-provider-submit"} > {isEdit ? ( "Save Changes" @@ -1986,12 +2103,15 @@ function ModelRowEditor({ function MappingRow({ header, sourceLabel, + "data-testid": dataTestId, }: { header: string; sourceLabel: string; + "data-testid"?: string; }) { return (
All models; + return ( + + All models + + ); } return ( - + {provider.models.length} configured ); @@ -196,7 +206,11 @@ export default function AgentProvidersTable({ const AddProviderButton = () => { const { openWizard } = useAIProviders(); return ( -