Compare commits

..
Author SHA1 Message Date
Viktor Liu a0f4a3564a Add CrowdSec AppSec mode to reverse proxy access control 2026-07-27 15:56:09 +02:00
Maycon Santosandbraginini 6ca91287fc Show agent-network prompt-cache tokens and cost on hover (#731)
Co-authored-by: braginini <bangvalo@gmail.com>
2026-07-26 21:43:33 +02:00
Misha Bragin 5a5e04ad35 Add 0.75 announcement (#727) 2026-07-23 21:05:52 +02:00
Maycon Santos 598ecb196d remove rc releases logic for agent networks (#725) 2026-07-23 20:29:09 +02:00
Maycon Santosandbraginini 7c8352822d Add Kimi (Moonshot AI) provider support to Agent Network UI (#724)
* Add Kimi (Moonshot AI) provider support to Agent Network UI

Pairs with the management catalog's new kimi_api entry:

- kimi_api in the AIProviderId union and the access-log provider-id
  normalization list.
- Provider modal: api.moonshot.ai host placeholder, upstream-URL help
  explaining the OpenAI-shape (/v1) vs Anthropic-shape (/anthropic)
  path choice, sk-... key placeholder.
- Configure Your Agent: Kimi backend option in the Claude Code tab
  (JSON + shell modes), a Kimi variant in the Codex tab using
  wire_api="chat", and a new Kimi CLI tab with the ~/.kimi/config.toml
  provider setup. All Kimi surfaces render only when a kimi_api
  provider is connected, and pre-select themselves when Kimi is the
  only provider of its shape.
- Onboarding defaults to the Claude Code tab for Kimi providers.

* Add Kimi provider e2e spec

Walks the Kimi lifecycle against the real backend: select kimi_api from
the catalog (prefilled host, catalog model pricing), connect it, then
assert the Kimi-gated Configure Your Agent surfaces (Kimi CLI tab,
Claude Code kimi-k3 config, Codex wire_api=chat variant) and clean up
via new agent-network API helpers. Skips when the management build
under test has no kimi_api catalog entry, so it can merge ahead of the
backend and activate once the management-cloud image carries it.

* Remove Codex option for Kimi and align Claude Code config with Moonshot guide

Codex has no clear support against Moonshot's upstream, so the Kimi
variant is dropped from the Codex tab. The Claude Code Kimi snippet now
pins all default model tiers and the subagent model to kimi-k3 and
disables tool search, per Moonshot's Claude Code guide.

* Carry the /anthropic shape prefix in the agent base URL for Kimi

The Kimi provider keeps its default https://api.moonshot.ai upstream;
Claude Code and Kimi CLI select the Anthropic shape by appending
/anthropic to the endpoint base URL, which rides through to Moonshot.
One provider now serves both API shapes.

* Kimi CLI uses the bare endpoint base URL

Only Claude Code carries the /anthropic prefix; Kimi CLI's anthropic
provider type points at the bare endpoint.

* Kimi tooltip

* Make the Claude Code provider dropdown width flexible and tidy Kimi notes

* Make the Claude Code provider dropdown width flexible and tidy Kimi notes

* Restored the note on the Kimi CLI tab

---------

Co-authored-by: braginini <bangvalo@gmail.com>
2026-07-23 19:24:41 +02:00
Maycon Santos e4f5046ff2 [agent-network] per-provider disable-metadata toggle (#715) 2026-07-20 14:46:17 +02:00
Eduard GertandClaude Opus 4.8 d5f61efc1a Fix network resource policy creation with single peer source (#721)
When creating a network resource and adding a policy with a single peer
(sourceResource) as the source, the payload included both an empty
`sources: []` array and `sourceResource`, causing the backend to reject
it with "specify either sources or source resources not both".

`createPolicyForResource` always computed `sources` as an array while the
`...rule` spread carried `sourceResource` through. This mirrors the
existing destinations handling: gate `sources` on `rule.sourceResource`
so it becomes undefined (and is dropped during serialization) when a
source resource is set.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:16:36 +02:00
Maycon Santos 3503141c56 Adjust license cache ttl to 10 minutes (#718) 2026-07-20 11:03:14 +02:00
Misha Bragin a27e4654cf Fix IdP cards spacing (#717) 2026-07-16 14:41:59 +02:00
Misha Bragin b9569f48d5 Add clusters view to agent network (#714) 2026-07-15 23:22:05 +02:00
Viktor Liu 9694bb753d Show RDP for Linux and FreeBSD peers and harden the browser RDP client (#710) 2026-07-15 12:29:54 +02:00
34 changed files with 1645 additions and 162 deletions

No files matched your search

+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
@@ -48,7 +48,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
@@ -57,7 +57,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
+3 -3
View File
@@ -1,8 +1,8 @@
[
{
"tag": "Preview",
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
"link": "https://github.com/netbirdio/netbird/discussions/6483",
"tag": "New",
"text": "The new NetBird desktop app is here - now available in the v0.75 release.",
"link": "https://netbird.io/knowledge-hub/netbird-v0-75-new-desktop-app",
"linkText": "Learn more",
"variant": "important",
"isExternal": true,
+50
View File
@@ -433,3 +433,53 @@ export async function deleteUserByEmail(page: Page, email: string) {
await deleteUserById(page, user.id);
}
}
// ---------------------------------------------------------------------------
// Agent Network
// ---------------------------------------------------------------------------
type AgentNetworkCatalogProvider = {
id: string;
name: string;
};
type AgentNetworkProvider = {
id: string;
name: string;
provider_id: string;
};
/** List the Agent Network provider catalog (server-defined provider types). */
export async function listAgentNetworkCatalog(
page: Page,
): Promise<AgentNetworkCatalogProvider[]> {
return apiGet<AgentNetworkCatalogProvider[]>(
page,
"/agent-network/catalog/providers",
);
}
/** List connected Agent Network providers. */
export async function listAgentNetworkProviders(
page: Page,
): Promise<AgentNetworkProvider[]> {
return apiGet<AgentNetworkProvider[]>(page, "/agent-network/providers");
}
/** Delete an Agent Network provider by ID. */
export async function deleteAgentNetworkProviderById(page: Page, id: string) {
await apiDelete(page, `/agent-network/providers/${id}`);
}
/** Delete all Agent Network providers whose name starts with the prefix. */
export async function deleteAgentNetworkProvidersByPrefix(
page: Page,
prefix: string,
) {
const providers = await listAgentNetworkProviders(page);
for (const p of providers) {
if (p.name.startsWith(prefix)) {
await deleteAgentNetworkProviderById(page, p.id);
}
}
}
@@ -0,0 +1,213 @@
/**
* Agent Network prompt-cache accounting spec.
*
* Drives the Usage & Logs page against mocked agent-network endpoints whose
* rows carry the cache fields (cached_input_tokens / cache_creation_tokens /
* cache_cost_usd) plus the per-bucket cost breakdown, and verifies the hover
* breakdowns: the access-log Tokens tooltip gains cache read/write rows and a
* cache-aware total, the Cost tooltip lists one line per billed bucket, and
* the usage overview's daily table
* surfaces the day's cache buckets. Route mocks keep the spec hermetic, so it
* passes against management builds that predate the cache fields.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
// The reported field scenario: a session-start request that wrote a ~32k-token
// prompt cache, and a follow-up that read it back.
const writeEntry = {
id: "anlog-cache-write",
service_id: "svc-e2e",
timestamp: new Date().toISOString(),
status_code: 200,
duration_ms: 29000,
input_tokens: 10,
output_tokens: 1472,
total_tokens: 33591,
cached_input_tokens: 0,
cache_creation_tokens: 32109,
cost_usd: 0.142519,
cache_cost_usd: 0.120409,
input_cost_usd: 0.00003,
cached_input_cost_usd: 0,
cache_creation_cost_usd: 0.120409,
output_cost_usd: 0.02208,
provider: "bedrock",
model: "anthropic.claude-sonnet-4-5",
session_id: "sess-cache-write",
decision: "allow",
stream: false,
};
const readEntry = {
...writeEntry,
id: "anlog-cache-read",
output_tokens: 800,
total_tokens: 32919,
cached_input_tokens: 32109,
cache_creation_tokens: 0,
cost_usd: 0.021663,
cache_cost_usd: 0.009633,
input_cost_usd: 0.00003,
cached_input_cost_usd: 0.009633,
cache_creation_cost_usd: 0,
output_cost_usd: 0.012,
session_id: "sess-cache-read",
};
const todayBucket = {
period_start: new Date().toISOString().slice(0, 10),
input_tokens: 20,
output_tokens: 2272,
total_tokens: 66510,
cached_input_tokens: 32109,
cache_creation_tokens: 32109,
cost_usd: 0.164182,
cache_cost_usd: 0.130042,
input_cost_usd: 0.00006,
cached_input_cost_usd: 0.009633,
cache_creation_cost_usd: 0.120409,
output_cost_usd: 0.03408,
};
async function mockCacheUsage(page: Page) {
await page.route("**/api/agent-network/access-logs*", (route) =>
route.fulfill({
json: {
data: [writeEntry, readEntry],
page: 1,
page_size: 25,
total_records: 2,
total_pages: 1,
},
}),
);
await page.route("**/api/agent-network/usage/overview*", (route) =>
route.fulfill({ json: [todayBucket] }),
);
}
async function newUsagePage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await mockCacheUsage(page);
await loginToApp(page, "owner");
return { page, close: () => context.close() };
}
// FullTooltip force-mounts its content, so a tooltip keeps its role=tooltip
// element in the DOM after closing — a bare getByRole("tooltip") accumulates
// every tooltip the test has ever opened. Radix puts role=tooltip on a
// visually-hidden span and the open/closed flag on that span's parent
// ("delayed-open"/"instant-open" vs "closed"), so match the parent that is
// currently open. Its text covers both the visible copy and the hidden one.
function tooltip(page: Page) {
return page.locator(
'[data-state="delayed-open"]:has(> [role="tooltip"]), [data-state="instant-open"]:has(> [role="tooltip"])',
);
}
// Radix's hoverable content holds a tooltip open while the pointer travels
// toward it, so moving straight to the next cell can leave two open at once.
// Escape dismisses the open one deterministically, wherever the pointer sits.
async function closeTooltip(page: Page) {
await page.keyboard.press("Escape");
await expect(tooltip(page)).toHaveCount(0);
}
test.describe.serial("Agent Network cache accounting @agent-network", () => {
test("access-log hover breaks out prompt-cache tokens and cost", async ({
browser,
}) => {
const { page, close } = await newUsagePage(browser);
try {
await navigateTo(page, "/agent-network/usage?tab=access-logs");
const writeRow = page.getByRole("row").filter({ hasText: "$0.1425" });
await expect(writeRow).toBeVisible();
// Tokens tooltip: cache-write bucket plus a total that includes it.
await writeRow.getByText("1,472").hover();
await expect(tooltip(page)).toContainText("32,109");
await expect(tooltip(page)).toContainText("cache write");
await expect(tooltip(page)).toContainText("33,591");
// Cost tooltip: all four buckets are listed plus the total, including
// buckets that cost nothing — this request wrote the cache but never read
// it, so "cache read" is present with a $0.0000 amount.
await closeTooltip(page);
await writeRow.getByText("$0.1425").hover();
await expect(tooltip(page)).toContainText("input");
await expect(tooltip(page)).toContainText("cache read");
await expect(tooltip(page)).toContainText("cache write");
await expect(tooltip(page)).toContainText("output");
await expect(tooltip(page)).toContainText("$0.1204");
await expect(tooltip(page)).toContainText("$0.0221");
await expect(tooltip(page)).toContainText("$0.0000");
await expect(tooltip(page)).toContainText("total");
// The follow-up request reads the cache back: read bucket filled, write
// bucket still listed as a zero row.
// No exact match: the output count renders behind an sr-only "Output:" prefix.
const readRow = page.getByRole("row").filter({ hasText: "$0.0217" });
await closeTooltip(page);
await readRow.getByText("800").hover();
await expect(tooltip(page)).toContainText("cache read");
await expect(tooltip(page)).toContainText("32,919");
await expect(tooltip(page)).toContainText(/0\s*cache write/);
} finally {
await close();
}
});
test("usage overview daily table surfaces the day's cache buckets", async ({
browser,
}) => {
const { page, close } = await newUsagePage(browser);
try {
await navigateTo(page, "/agent-network/usage");
// Row-scoped on purpose: the hovers now repeat the cell's own figure on
// their total row, and FullTooltip force-mounts that content, so a
// page-wide getByText would match the cell and the tooltip both. Radix
// portals tooltip content to <body>, outside the row.
const dayRow = page.getByRole("row").filter({ hasText: "$0.16" });
// Total Tokens includes the additive cache buckets; hover splits them out
// in the same value-then-label rows the access-log table uses.
await expect(dayRow.getByText("66,510")).toBeVisible();
await dayRow.getByText("66,510").hover();
await expect(tooltip(page)).toContainText(/20\s*input/);
await expect(tooltip(page)).toContainText(/2,272\s*output/);
await expect(tooltip(page)).toContainText(/32,109\s*cache read/);
await expect(tooltip(page)).toContainText(/32,109\s*cache write/);
await expect(tooltip(page)).toContainText(/66,510\s*total/);
// Cost hover breaks the day into the same four billed buckets.
await closeTooltip(page);
await dayRow.getByText("$0.16", { exact: true }).hover();
await expect(tooltip(page)).toContainText(/\$0\.0001\s*input/);
await expect(tooltip(page)).toContainText(/\$0\.0341\s*output/);
await expect(tooltip(page)).toContainText(/\$0\.0096\s*cache read/);
await expect(tooltip(page)).toContainText(/\$0\.1204\s*cache write/);
await expect(tooltip(page)).toContainText(/\$0\.1642\s*total/);
} finally {
await close();
}
});
});
@@ -0,0 +1,165 @@
/**
* Agent Network Kimi (Moonshot AI) provider spec.
*
* Walks the Kimi provider lifecycle end to end against the real backend:
* pick kimi_api from the catalog (prefilled host, catalog models with
* pricing), connect it, then verify the Kimi-gated config surfaces in the
* "Configure Your Agent" modal (Kimi CLI tab, Kimi backend option in the
* Claude Code tab) that only render when a Kimi provider is connected.
*
* The kimi_api catalog entry ships with newer management builds. When the
* backend under test predates it, the whole suite skips instead of failing —
* same trade-off as the provider matrix in netbird's agent-network e2e
* harness, which skips per-provider on missing credentials.
*
* The Agent Network menu is deployment-gated; the test build honors the
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
* set via addInitScript on a dedicated context below.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
} from "../helpers/api";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const KIMI_CATALOG_ID = "kimi_api";
const KIMI_CATALOG_NAME = "Kimi (Moonshot AI) API";
const PROVIDER_PREFIX = "e2e-kimi-";
async function newAgentNetworkPage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await loginToApp(page, "owner");
return { page, close: () => context.close() };
}
test.describe.serial("Agent Network Kimi provider @agent-network", () => {
test("connect a Kimi provider and see Kimi agent configs", async ({
browser,
}) => {
const { page, close } = await newAgentNetworkPage(browser);
try {
// Backend support probe: skip the suite on management builds whose
// catalog predates kimi_api.
const catalog = await listAgentNetworkCatalog(page);
test.skip(
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
`management catalog has no ${KIMI_CATALOG_ID} entry`,
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
// ---- connect the provider ----
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
// The provider select defaults to OpenAI API; search the catalog for
// the Kimi entry.
await page
.getByRole("button", { name: /OpenAI API/ })
.first()
.click({ force: true });
await page.getByPlaceholder("Search providers...").fill("kimi");
await page.getByText(KIMI_CATALOG_NAME).first().click({ force: true });
// Catalog default host lands in the upstream URL input.
await expect(
page.locator('input[value="https://api.moonshot.ai"]'),
).toBeVisible();
await page
.getByPlaceholder("sk-...")
.first()
.fill("sk-e2e-kimi-test-key");
const providerName = generateRandomName(PROVIDER_PREFIX);
const nameInput = page.locator(`input[value="${KIMI_CATALOG_NAME}"]`);
await nameInput.fill(providerName);
// ---- models tab: catalog models carry pricing ----
await page.getByRole("tab", { name: "Models" }).click({ force: true });
await page.getByRole("button", { name: "Add More" }).click({ force: true });
await page.getByText("Kimi K3", { exact: false }).first().click({ force: true });
// Catalog price for kimi-k3 input tokens pre-fills the price cell.
await expect(page.locator('input[value="0.003"]')).toBeVisible();
const createResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/providers") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
expect([200, 201]).toContain((await createResponse).status());
// Row lands in the providers table.
await expect(page.getByText(providerName).first()).toBeVisible();
// ---- Kimi-gated agent config surfaces ----
await page
.getByRole("button", { name: "Agent Config" })
.click({ force: true });
// Kimi CLI tab only renders when a kimi_api provider is connected.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
// snippet pins every model slot to kimi-k3 and disables tool search,
// per Moonshot's Claude Code guide.
await expect(page.getByText('"ANTHROPIC_MODEL": "kimi-k3"')).toBeVisible();
await expect(
page.getByText('"CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k3"'),
).toBeVisible();
await expect(
page.getByText('"ENABLE_TOOL_SEARCH": "false"'),
).toBeVisible();
// The base URL carries the /anthropic shape prefix that rides through
// the endpoint to Moonshot's Anthropic surface.
await expect(
page.getByText(/"ANTHROPIC_BASE_URL": "https:\/\/[^"]+\/anthropic"/),
).toBeVisible();
// Kimi CLI tab carries the ~/.kimi/config.toml provider block.
await page.getByRole("tab", { name: "Kimi CLI" }).click({ force: true });
await expect(page.getByText('default_model = "kimi-k3"')).toBeVisible();
// Codex has no Kimi variant — Kimi's upstream doesn't support Codex, so
// the tab keeps the plain Responses-API config with no backend dropdown.
await page.getByRole("tab", { name: "Codex" }).click({ force: true });
await expect(page.getByText('wire_api = "responses"')).toBeVisible();
await expect(page.getByText('wire_api = "chat"')).not.toBeVisible();
await page.keyboard.press("Escape");
// ---- cleanup ----
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
} finally {
await close();
}
});
});
+37 -12
View File
@@ -1,6 +1,6 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { clearScrollLock, generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api";
let setupKeys: string[] = [];
@@ -118,19 +118,34 @@ async function createSetupKey(
await expect(page.getByText(opts.name)).toBeVisible();
}
async function revokeSetupKey(
// openRowActions opens a row's action dropdown. A confirmation dialog closed
// moments earlier can still leave Radix's scroll-lock artifacts on <body>
// (`pointer-events: none`, a stale overlay), and those swallow the click even
// with force — the menu then silently never opens and the item lookup times
// out. Clear them before clicking.
async function openRowActions(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await clearScrollLock(page);
await page
.locator("tr")
.filter({ hasText: name })
.getByTestId("setup-key-actions")
.click({ force: true });
await page
.locator('[data-testid="revoke-setup-key"]:not([data-disabled])')
.click({ force: true });
}
async function revokeSetupKey(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await openRowActions(page, name);
const revokeItem = page.locator(
'[data-testid="revoke-setup-key"]:not([data-disabled])',
);
await expect(revokeItem).toBeVisible();
await revokeItem.click({ force: true });
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT",
{ timeout: 10_000 },
@@ -151,13 +166,23 @@ async function deleteSetupKey(
name: string,
) {
// Row actions are now behind a dropdown menu.
await page
.locator("tr")
.filter({ hasText: name })
.getByTestId("setup-key-actions")
.click({ force: true });
await page.getByTestId("delete-setup-key").click({ force: true });
await openRowActions(page, name);
const deleteItem = page.locator(
'[data-testid="delete-setup-key"]:not([data-disabled])',
);
await expect(deleteItem).toBeVisible();
await deleteItem.click({ force: true });
// Await the DELETE like revoke awaits its PUT: the handler refetches
// /setup-keys afterwards, which re-renders every row — returning before that
// settles means the next key's trigger click can land on a replaced node.
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/setup-keys/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
);
await page.getByTestId("confirmation.confirm").click();
await responsePromise;
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
await expect(page.getByRole("dialog")).not.toBeVisible();
}
@@ -1,25 +1,33 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import * as Tabs from "@radix-ui/react-tabs";
import { Gauge, ScrollText } from "lucide-react";
import { ExternalLinkIcon, Gauge, ScrollText, ServerIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { Suspense, useEffect, useState } from "react";
import React, { lazy, Suspense, useEffect, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
const ClustersTable = lazy(
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
);
const TAB_BUDGET_SETTINGS = "budget-settings";
const TAB_LOG_SETTINGS = "log-settings";
const TAB_CLUSTERS = "clusters";
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
// Usage & Logs — global limits and log-collection controls — using the same
@@ -27,6 +35,7 @@ const TAB_LOG_SETTINGS = "log-settings";
// Settings page so it reads as "settings for the Agent Network".
export default function AgentNetworkConfigurationPage() {
const { permission } = usePermissions();
const { only: agentNetworkOnly } = useAgentNetworkMode();
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
@@ -47,6 +56,10 @@ export default function AgentNetworkConfigurationPage() {
<ScrollText size={14} />
Log Collection
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
<ServerIcon size={14} />
Clusters
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess
page={"Configuration"}
@@ -82,6 +95,27 @@ export default function AgentNetworkConfigurationPage() {
<AgentAccountControlsCard />
</Suspense>
</Tabs.Content>
<Tabs.Content value={TAB_CLUSTERS} className={"w-full"}>
<ConfigTabHeader
label={"Clusters"}
href={"/agent-network/configuration?tab=clusters"}
>
{agentNetworkOnly
? "Proxy clusters route your agents' traffic to AI providers and run on your own infrastructure. Add multiple clusters to scale your environment."
: "Proxy clusters route inbound traffic to your services. Shared clusters are run by the platform; account clusters (self-hosted) run on your own infrastructure."}{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</ConfigTabHeader>
<Suspense fallback={<SkeletonTable />}>
<ClustersTable />
</Suspense>
</Tabs.Content>
</div>
</AIProvidersProvider>
</PeersProvider>
+16 -1
View File
@@ -7,6 +7,8 @@ import useFetchApi from "@utils/api";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Peer } from "@/interfaces/Peer";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
@@ -138,6 +140,8 @@ function RDPSession({ peer }: Props) {
domain: credentials.domain,
width: window.innerWidth,
height: window.innerHeight,
dynamicResize:
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS,
});
if (result === RDPStatus.CONNECTED) {
connected.current = true;
@@ -182,7 +186,18 @@ function RDPSession({ peer }: Props) {
if (client.error) {
sendErrorNotification("NetBird Client Error", client.error);
}
}, [rdp, client]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rdp.error, client.error]);
/**
* Return to the credentials screen when an established session drops with an
* error, so the user can see what happened and retry.
*/
useEffect(() => {
if (connected.current && rdp.error) {
reset();
}
}, [rdp.error, reset]);
/**
* Close credentials modal when RDP is connected
+5 -3
View File
@@ -64,9 +64,11 @@ export default function PoliciesProvider({ children }: Props) {
return created.id!;
};
const sources = await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const sources = rule.sourceResource
? undefined
: await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const destinations = rule.destinationResource
? undefined
+1 -1
View File
@@ -21,7 +21,7 @@ const ENDPOINT_EXISTS_ERROR_CODES = [401, 403, 405];
* activation be picked up without forcing the user to clear storage.
*/
const LICENSE_CACHE_PREFIX = "netbird-licensed:";
const LICENSE_CACHE_TTL_MS = 60 * 60 * 1000;
const LICENSE_CACHE_TTL_MS = 60 * 10 * 1000;
const licenseCacheKey = () => `${LICENSE_CACHE_PREFIX}${loadConfig().apiOrigin}`;
+11
View File
@@ -33,12 +33,21 @@ export const CrowdSecMode = {
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
export const AppSecMode = {
OFF: "off",
ENFORCE: "enforce",
OBSERVE: "observe",
} as const;
export type AppSecMode = (typeof AppSecMode)[keyof typeof AppSecMode];
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode;
appsec_mode?: AppSecMode;
}
export interface ReverseProxyMeta {
@@ -122,6 +131,7 @@ export interface ReverseProxyDomain {
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
supports_appsec?: boolean;
supports_private?: boolean;
}
@@ -188,6 +198,7 @@ export interface ReverseProxyCluster {
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
supports_appsec?: boolean;
// True when at least one connected proxy in this cluster is running embedded
// in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel.
// Lets the dashboard distinguish per-peer / private clusters from centralised
+103 -5
View File
@@ -113,6 +113,8 @@ function upstreamUrlPlaceholder(providerId: AIProviderId): string {
return "https://api.portkey.ai";
case "vllm":
return "https://your-vllm-host:8000";
case "kimi_api":
return "https://api.moonshot.ai";
case "custom":
return "https://your-llm-host";
default:
@@ -217,6 +219,9 @@ export default function AIProviderModal({
const [skipTlsVerification, setSkipTlsVerification] = useState<boolean>(
provider?.skipTlsVerification ?? false,
);
const [metadataDisabled, setMetadataDisabled] = useState<boolean>(
provider?.metadataDisabled ?? false,
);
const catalog = getById(providerId);
// Custom-kind providers (the generic "Custom" entry and named self-hosted
@@ -262,7 +267,8 @@ export default function AIProviderModal({
providerId === "bifrost" ||
providerId === "cloudflare_ai_gateway" ||
providerId === "vercel_ai_gateway" ||
providerId === "openrouter";
providerId === "openrouter" ||
providerId === "bedrock_api";
// 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
@@ -340,6 +346,7 @@ export default function AIProviderModal({
setIdentityHeaderUserId(provider.identityHeaderUserId ?? "");
setIdentityHeaderGroups(provider.identityHeaderGroups ?? "");
setSkipTlsVerification(provider.skipTlsVerification ?? false);
setMetadataDisabled(provider.metadataDisabled ?? false);
} else {
const fallback = getById("openai_api");
setProviderId("openai_api");
@@ -354,6 +361,7 @@ export default function AIProviderModal({
setIdentityHeaderUserId("");
setIdentityHeaderGroups("");
setSkipTlsVerification(false);
setMetadataDisabled(false);
}
};
@@ -405,6 +413,7 @@ export default function AIProviderModal({
extraValues: sanitizedExtraValues,
...identityOverrides,
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
metadataDisabled,
// Only forward the API key when the user actually rotated it
...(apiKey && apiKey !== "••••••••" ? { apiKey } : {}),
});
@@ -420,6 +429,7 @@ export default function AIProviderModal({
extraValues: sanitizedExtraValues,
...identityOverrides,
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
metadataDisabled,
models,
enabled: true,
});
@@ -609,7 +619,20 @@ export default function AIProviderModal({
</FormRow>
<FormRow
label={"Upstream URL"}
label={
providerId === "kimi_api" ? (
<>
Upstream URL
<HelpTooltip
content={
"Moonshot AI's international platform endpoint. Keep the bare host. Moonshot serves both API shapes with the same key: the path an agent calls rides through to Moonshot, so its base URL picks the shape (Claude Code appends /anthropic; Kimi CLI and OpenAI shaped callers use the bare endpoint). Mainland China accounts use api.moonshot.cn instead."
}
/>
</>
) : (
"Upstream URL"
)
}
helpText={upstreamUrlHelpText(providerId)}
>
<Input
@@ -730,7 +753,7 @@ export default function AIProviderModal({
onChange={(e) => setApiKey(e.target.value)}
customPrefix={<KeyRound size={14} />}
placeholder={
providerId === "openai_api"
providerId === "openai_api" || providerId === "kimi_api"
? "sk-..."
: providerId === "anthropic_api"
? "sk-ant-..."
@@ -785,6 +808,23 @@ export default function AIProviderModal({
{showMappings && providerId === "litellm_proxy" && (
<TabsContent value={"mappings"} className={"pb-8"}>
<div className={"px-8 pt-3 flex-col flex gap-4"}>
{/* The forwarding toggle sits first: it gates the identity
mappings described below, so turning it off makes the fixed
mapping that follows moot. */}
<FancyToggleSwitch
value={!metadataDisabled}
onChange={(v) => setMetadataDisabled(!v)}
label={
<>
<ArrowRightLeft size={15} />
Forward Identity Metadata
</>
}
helpText={
"Stamp the identity mappings below onto LiteLLM requests."
}
/>
<div>
<Label>Identity Mappings</Label>
<HelpText className={"mb-0"}>
@@ -987,6 +1027,61 @@ export default function AIProviderModal({
</TabsContent>
)}
{showMappings && providerId === "bedrock_api" && (
<TabsContent value={"mappings"} className={"pb-8"}>
<div className={"px-8 pt-3 flex-col flex gap-4"}>
{/* The forwarding toggle sits first: it gates the identity
metadata described below, so turning it off makes the fixed
mapping that follows moot. */}
<FancyToggleSwitch
value={!metadataDisabled}
onChange={(v) => setMetadataDisabled(!v)}
label={
<>
<ArrowRightLeft size={15} />
Forward Identity Metadata
</>
}
helpText={
"Stamp the identity metadata below onto Bedrock requests."
}
/>
<div>
<Label>Identity Metadata</Label>
<HelpText className={"mb-0"}>
NetBird stamps the caller&apos;s identity into the{" "}
<InlineLink
href={
"https://docs.aws.amazon.com/bedrock/latest/userguide/cost-mgmt-request-metadata.html"
}
target={"_blank"}
>
<code
className={
"text-xs font-mono bg-nb-gray-900/60 rounded px-1.5 py-0.5"
}
>
X-Amzn-Bedrock-Request-Metadata
</code>
</InlineLink>{" "}
header, so you can break Bedrock spend down by user and
group. Client-supplied values are stripped and sanitized.
</HelpText>
</div>
<div
className={
"rounded-md overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30"
}
>
<MappingRow header={"user"} sourceLabel={"User Email"} />
<MappingRow header={"group"} sourceLabel={"Groups"} />
</div>
</div>
</TabsContent>
)}
{showMappings && providerId === "vercel_ai_gateway" && (
<TabsContent value={"mappings"} className={"pb-8"}>
<div className={"px-8 pt-3 flex-col flex gap-4"}>
@@ -1164,8 +1259,11 @@ export default function AIProviderModal({
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={"https://docs.netbird.io/"} target={"_blank"}>
Agent Network
<InlineLink
href={"https://docs.netbird.io/agent-network/providers"}
target={"_blank"}
>
Agent Network Providers
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
@@ -50,6 +50,9 @@ export type APIProvider = {
// Skip TLS certificate verification on upstream requests (custom providers
// with self-signed certs). Off by default.
skip_tls_verification?: boolean;
// Disable identity metadata injection (caller user + authorizing group) on
// upstream requests. Off by default (metadata is sent).
metadata_disabled?: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
@@ -67,6 +70,7 @@ export type APIProviderRequest = {
identity_header_user_id?: string;
identity_header_groups?: string;
skip_tls_verification?: boolean;
metadata_disabled?: boolean;
models: APIProviderModel[];
enabled?: boolean;
};
@@ -84,6 +88,8 @@ export type ProviderConnectInput = {
identityHeaderGroups?: string;
// Skip upstream TLS verification (custom providers with self-signed certs).
skipTlsVerification?: boolean;
// Disable identity metadata injection (user + authorizing group). Off by default.
metadataDisabled?: boolean;
models: ProviderModel[];
enabled?: boolean;
};
@@ -97,6 +103,7 @@ export type ProviderUpdateInput = {
identityHeaderUserId?: string;
identityHeaderGroups?: string;
skipTlsVerification?: boolean;
metadataDisabled?: boolean;
models?: ProviderModel[];
enabled?: boolean;
};
@@ -159,6 +166,7 @@ function fromAPI(p: APIProvider): AIProvider {
identityHeaderUserId: p.identity_header_user_id,
identityHeaderGroups: p.identity_header_groups,
skipTlsVerification: p.skip_tls_verification ?? false,
metadataDisabled: p.metadata_disabled ?? false,
status: p.enabled ? "active" : "disabled",
models,
allowedGroups: [],
@@ -201,6 +209,7 @@ function toCreateRequest(input: ProviderConnectInput): APIProviderRequest {
identity_header_user_id: input.identityHeaderUserId,
identity_header_groups: input.identityHeaderGroups,
skip_tls_verification: input.skipTlsVerification,
metadata_disabled: input.metadataDisabled,
models: toAPIModels(input.models),
enabled: input.enabled,
};
@@ -647,6 +656,8 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
updates.identityHeaderGroups ?? existing.identity_header_groups,
skip_tls_verification:
updates.skipTlsVerification ?? existing.skip_tls_verification,
metadata_disabled:
updates.metadataDisabled ?? existing.metadata_disabled,
models: updates.models
? toAPIModels(updates.models)
: existing.models ?? [],
@@ -26,17 +26,39 @@ export default function AgentAccessLogExpandedRow({ entry }: Readonly<Props>) {
const hasBody = hasPrompt || hasCompletion;
const denyReason = formatDenyReason(entry.denyReason);
const cacheRead = entry.cachedInputTokens ?? 0;
const cacheWrite = entry.cacheCreationTokens ?? 0;
const metadata: Record<string, string> = {
"plg.llm.provider": entry.providerId,
"plg.llm.model": entry.model,
"plg.llm.input_tokens": String(entry.inputTokens),
"plg.llm.output_tokens": String(entry.outputTokens),
"plg.llm.total_tokens": String(entry.inputTokens + entry.outputTokens),
"plg.llm.total_tokens": String(
entry.inputTokens + entry.outputTokens + cacheRead + cacheWrite,
),
"plg.llm.cost_usd": entry.costUsd.toFixed(6),
"plg.llm.stream": entry.stream ? "true" : "false",
"plg.agentnetwork.policy_name": entry.policyName,
"plg.agentnetwork.user_groups": (entry.userGroups ?? []).join(", "),
};
if (cacheRead > 0 || cacheWrite > 0) {
metadata["plg.llm.cached_input_tokens"] = String(cacheRead);
metadata["plg.llm.cache_creation_tokens"] = String(cacheWrite);
metadata["plg.cost.usd_cache"] = (entry.cacheCostUsd ?? 0).toFixed(6);
}
// Per-bucket cost keys mirror the proxy's cost.usd_* metadata. Emitted only
// when the server sent the breakdown, so a row from an older management
// server shows no misleading zeros.
if (entry.inputCostUsd !== undefined || entry.outputCostUsd !== undefined) {
metadata["plg.cost.usd_input"] = (entry.inputCostUsd ?? 0).toFixed(6);
metadata["plg.cost.usd_cached_input"] = (
entry.cachedInputCostUsd ?? 0
).toFixed(6);
metadata["plg.cost.usd_cache_creation"] = (
entry.cacheCreationCostUsd ?? 0
).toFixed(6);
metadata["plg.cost.usd_output"] = (entry.outputCostUsd ?? 0).toFixed(6);
}
if (isDeny) {
metadata["plg.llm_policy.decision"] = "deny";
metadata["plg.llm_policy.reason"] = entry.denyReason ?? "unknown";
+137 -18
View File
@@ -261,13 +261,14 @@ export default function AgentAccessLogTable({
</DataTableHeader>
),
cell: ({ row }) => (
<span
className={
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap"
}
>
${row.original.costUsd.toFixed(4)}
</span>
<CostCell
costUsd={row.original.costUsd}
cacheCostUsd={row.original.cacheCostUsd}
inputCostUsd={row.original.inputCostUsd}
cachedInputCostUsd={row.original.cachedInputCostUsd}
cacheCreationCostUsd={row.original.cacheCreationCostUsd}
outputCostUsd={row.original.outputCostUsd}
/>
),
},
{
@@ -409,6 +410,8 @@ export default function AgentAccessLogTable({
{
inputTokens: row.original.inputTokens,
outputTokens: row.original.outputTokens,
cachedInputTokens: row.original.cachedInputTokens,
cacheCreationTokens: row.original.cacheCreationTokens,
} as AIAccessLogEntry
}
/>
@@ -423,13 +426,14 @@ export default function AgentAccessLogTable({
</DataTableHeader>
),
cell: ({ row }) => (
<span
className={
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap"
}
>
${row.original.costUsd.toFixed(4)}
</span>
<CostCell
costUsd={row.original.costUsd}
cacheCostUsd={row.original.cacheCostUsd}
inputCostUsd={row.original.inputCostUsd}
cachedInputCostUsd={row.original.cachedInputCostUsd}
cacheCreationCostUsd={row.original.cacheCreationCostUsd}
outputCostUsd={row.original.outputCostUsd}
/>
),
},
{
@@ -999,31 +1003,43 @@ function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
) {
return <EmptyRow />;
}
const total = (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
const cacheRead = entry.cachedInputTokens ?? 0;
const cacheWrite = entry.cacheCreationTokens ?? 0;
// Anthropic-shape cache buckets are additive to input tokens, so they count toward the total.
const total =
(entry.inputTokens ?? 0) +
(entry.outputTokens ?? 0) +
cacheRead +
cacheWrite;
return (
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1"}>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<ArrowUpIcon size={12} className={"text-sky-400 shrink-0"} />
<span className={"font-medium"}>
{entry.inputTokens.toLocaleString()}
</span>
<span className={"text-nb-gray-400"}>input</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<ArrowDownIcon size={12} className={"text-netbird shrink-0"} />
<span className={"font-medium"}>
{entry.outputTokens.toLocaleString()}
</span>
<span className={"text-nb-gray-400"}>output</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{cacheRead.toLocaleString()}</span>
<span className={"text-nb-gray-400"}>cache read</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{cacheWrite.toLocaleString()}</span>
<span className={"text-nb-gray-400"}>cache write</span>
</div>
<div
className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
}
>
<span className={"w-3 shrink-0"} aria-hidden={true} />
<span className={"font-medium text-nb-gray-200"}>
{total.toLocaleString()}
</span>
@@ -1050,6 +1066,109 @@ function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
);
}
// CostRow is one line of the Cost hover breakdown: an amount plus its label.
function CostRow({ amount, label }: { amount: number; label: string }) {
return (
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>${amount.toFixed(4)}</span>
<span className={"text-nb-gray-400 font-sans"}>{label}</span>
</div>
);
}
// CostCell renders the metered USD cost with a hover breakdown of the buckets
// it was billed from.
//
// Servers that send the per-bucket breakdown get one line per bucket the
// provider bills separately (input / output / cache read / cache write). Older
// servers send only the two aggregates, so the hover falls back to the coarse
// "input + output" vs "cache" split derivable from those — the two shapes are
// distinguished by whether inputCostUsd is defined, not by whether it is zero.
function CostCell({
costUsd,
cacheCostUsd,
inputCostUsd,
cachedInputCostUsd,
cacheCreationCostUsd,
outputCostUsd,
}: {
costUsd: number;
cacheCostUsd?: number;
inputCostUsd?: number;
cachedInputCostUsd?: number;
cacheCreationCostUsd?: number;
outputCostUsd?: number;
}) {
const cache = cacheCostUsd ?? 0;
const hasBreakdown =
inputCostUsd !== undefined || outputCostUsd !== undefined;
// Nothing was metered: the request never reached a provider (denied before
// routing), or ran on a model the proxy deliberately doesn't price. A dash
// reads as "not metered" — matching TokensCell — where "$0.0000" plus a
// tooltip of zeros would imply a priced request that happened to be free.
const buckets = [
inputCostUsd ?? 0,
cachedInputCostUsd ?? 0,
cacheCreationCostUsd ?? 0,
outputCostUsd ?? 0,
];
if (costUsd === 0 && cache === 0 && buckets.every((b) => b === 0)) {
return <EmptyRow />;
}
const display = (
<span
className={
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap"
}
>
${costUsd.toFixed(4)}
</span>
);
// Nothing to break out: no cache spend and no per-bucket split to show.
if (cache <= 0 && !hasBreakdown) return display;
const cacheRead = cachedInputCostUsd ?? 0;
const cacheWrite = cacheCreationCostUsd ?? 0;
return (
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1 font-mono"}>
{hasBreakdown ? (
<>
{/* All four buckets, including zeros: a zero cache-read line is
information (the request missed the cache), and a fixed set of
rows keeps the hover comparable between requests. */}
<CostRow amount={inputCostUsd ?? 0} label={"input"} />
<CostRow amount={outputCostUsd ?? 0} label={"output"} />
<CostRow amount={cacheRead} label={"cache read"} />
<CostRow amount={cacheWrite} label={"cache write"} />
</>
) : (
<>
<CostRow amount={costUsd - cache} label={"input + output"} />
<CostRow amount={cache} label={"cache"} />
</>
)}
<div
className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
}
>
<span className={"font-medium text-nb-gray-200"}>
${costUsd.toFixed(4)}
</span>
<span className={"font-sans"}>total</span>
</div>
</div>
}
>
{display}
</FullTooltip>
);
}
// resolveProviderFn resolves a request's configured provider (by the router's
// stamped resolved_provider_id) — shared by the session cells so they reuse the
// flat row's provider resolution.
+138 -7
View File
@@ -2,6 +2,7 @@
import Code from "@components/Code";
import { Modal, ModalContent } from "@components/modal/Modal";
import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider";
import Paragraph from "@components/Paragraph";
import SmallParagraph from "@components/SmallParagraph";
import SquareIcon from "@components/SquareIcon";
@@ -56,6 +57,7 @@ export function AgentConnectTabs({
listClassName = "px-8",
contentClassName = "px-6 py-2",
defaultTab = "claude-code",
providerIds = [],
}: {
endpoint: string;
listClassName?: string;
@@ -64,23 +66,39 @@ export function AgentConnectTabs({
// matching tool (e.g. Anthropic → claude-code, OpenAI → curl). Keyed below
// so a late-resolving defaultTab still re-initialises the tabs.
defaultTab?: string;
// Catalog ids of the account's connected providers. Kimi-specific config
// surfaces (the Kimi CLI tab and the Kimi variant inside the Claude Code
// tab) only render when a kimi_api provider is actually connected —
// showing Moonshot setup against an endpoint that can't route to Kimi
// would just be a trap.
providerIds?: string[];
}) {
const baseUrl = `https://${endpoint}`;
const openaiBase = `${baseUrl}/v1`;
const hasKimi = providerIds.includes("kimi_api");
const [claudeMode, setClaudeMode] = React.useState<"config" | "shell">(
"config",
);
// Which backend the Claude Code config targets — Anthropic API direct, or
// via Vertex AI / Bedrock. Switched in-tab instead of separate tabs.
// Which backend the Claude Code config targets — Anthropic API direct,
// via Vertex AI / Bedrock, or Kimi (Moonshot AI, whose upstream speaks the
// Anthropic Messages API too). Switched in-tab instead of separate tabs.
// When Kimi is the only Anthropic-shaped provider connected, it's the only
// config that can work, so start there.
const kimiOnlyAnthropicShape =
hasKimi &&
!["anthropic_api", "vertex_ai_api", "bedrock_api"].some((id) =>
providerIds.includes(id),
);
const [claudeProvider, setClaudeProvider] = React.useState<
"anthropic" | "vertex" | "bedrock"
>("anthropic");
"anthropic" | "vertex" | "bedrock" | "kimi"
>(kimiOnlyAnthropicShape ? "kimi" : "anthropic");
return (
<Tabs key={defaultTab} defaultValue={defaultTab} className={"mt-2"}>
<TabsList justify={"start"} className={listClassName}>
<TabsTrigger value={"claude-code"}>Claude Code</TabsTrigger>
<TabsTrigger value={"codex"}>Codex</TabsTrigger>
{hasKimi && <TabsTrigger value={"kimi-cli"}>Kimi CLI</TabsTrigger>}
<TabsTrigger value={"openai-sdk"}>OpenAI SDK</TabsTrigger>
<TabsTrigger value={"curl"}>cURL</TabsTrigger>
</TabsList>
@@ -91,15 +109,20 @@ export function AgentConnectTabs({
<SelectDropdown
value={claudeProvider}
onChange={(v) =>
setClaudeProvider(v as "anthropic" | "vertex" | "bedrock")
setClaudeProvider(
v as "anthropic" | "vertex" | "bedrock" | "kimi",
)
}
options={[
{ label: "Anthropic API", value: "anthropic" },
{ label: "Vertex AI", value: "vertex" },
{ label: "Bedrock", value: "bedrock" },
...(hasKimi
? [{ label: "Kimi (Moonshot AI)", value: "kimi" }]
: []),
]}
showValues={false}
className={"w-[160px]"}
className={"!w-auto min-w-[160px]"}
/>
</div>
@@ -176,6 +199,76 @@ export function AgentConnectTabs({
]}
/>
)}
{claudeProvider === "kimi" && (
<>
<div className={"flex items-center justify-between gap-3 mb-2"}>
<SmallParagraph className={"!mb-0"}>
{claudeMode === "config"
? "Add to ~/.claude/settings.json:"
: "Run in your shell:"}
</SmallParagraph>
<button
type={"button"}
onClick={() =>
setClaudeMode(claudeMode === "config" ? "shell" : "config")
}
className={
"shrink-0 mr-2 text-[11px] text-white hover:underline underline-offset-2 cursor-pointer"
}
>
{claudeMode === "config" ? "Shell" : "JSON"}
</button>
</div>
<Snippet
// Claude Code speaks the Anthropic Messages API, which
// Moonshot serves under the /anthropic path prefix. The
// prefix goes in the agent's base URL and rides through the
// endpoint to the bare https://api.moonshot.ai upstream, so
// one Kimi provider serves both API shapes. Every model slot
// Claude Code fills on its own (opus/sonnet/haiku tiers,
// subagents) is pinned to kimi-k3 so no Claude model names
// leak into requests the upstream can't serve, and tool
// search is off because Moonshot rejects its tool_reference
// blocks — both per Moonshot's Claude Code guide.
lines={
claudeMode === "config"
? [
`{`,
` "apiKeyHelper": "echo '-'",`,
` "env": {`,
` "ANTHROPIC_BASE_URL": "${baseUrl}/anthropic",`,
` "ANTHROPIC_MODEL": "kimi-k3",`,
` "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k3",`,
` "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k3",`,
` "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-k3",`,
` "CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k3",`,
` "ENABLE_TOOL_SEARCH": "false"`,
` }`,
`}`,
]
: [
`export ANTHROPIC_BASE_URL=${baseUrl}/anthropic`,
`export ANTHROPIC_API_KEY=none`,
`export ANTHROPIC_MODEL=kimi-k3`,
`export ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k3`,
`export ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k3`,
`export ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k3`,
`export CLAUDE_CODE_SUBAGENT_MODEL=kimi-k3`,
`export ENABLE_TOOL_SEARCH=false`,
`claude`,
]
}
/>
<SmallParagraph className={"mt-3"}>
Pairs with a Kimi provider keeping the default upstream URL{" "}
<code className={"font-mono"}>https://api.moonshot.ai</code>.
The <code className={"font-mono"}>/anthropic</code> suffix in
the base URL rides through the endpoint to Moonshot, which
serves the Anthropic Messages API under that path.
</SmallParagraph>
</>
)}
</div>
</TabsContent>
@@ -195,6 +288,38 @@ export function AgentConnectTabs({
</div>
</TabsContent>
<TabsContent value={"kimi-cli"}>
<div className={contentClassName} hidden={!hasKimi}>
<Snippet
// Kimi CLI reads providers from ~/.kimi/config.toml. Unlike
// Claude Code, its "anthropic" provider type needs the bare
// endpoint — no /anthropic prefix in base_url; api_key is a
// placeholder since NetBird injects the real key server-side.
caption={"Add to ~/.kimi/config.toml:"}
lines={[
`default_model = "kimi-k3"`,
``,
`[providers.netbird]`,
`type = "anthropic"`,
`base_url = "${baseUrl}"`,
`api_key = "-"`,
``,
`[models.kimi-k3]`,
`provider = "netbird"`,
`model = "kimi-k3"`,
`max_context_size = 1000000`,
]}
/>
<SmallParagraph className={"mt-3"}>
Pairs with a Kimi provider keeping the default upstream URL{" "}
<code className={"font-mono"}>https://api.moonshot.ai</code>. For
the OpenAI shape instead, use{" "}
<code className={"font-mono"}>type = &quot;openai_legacy&quot;</code>{" "}
with <code className={"font-mono"}>base_url = &quot;{openaiBase}&quot;</code>.
</SmallParagraph>
</div>
</TabsContent>
<TabsContent value={"openai-sdk"}>
<div className={contentClassName}>
<Snippet
@@ -243,6 +368,9 @@ export default function AgentConnectModal({
onOpenChange,
endpoint,
}: Readonly<Props>) {
// Rendered inside <AIProvidersProvider> (providers page); the connected
// provider ids gate which per-tool config variants the tabs offer.
const { providers } = useAIProviders();
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass={"max-w-2xl"}>
@@ -258,7 +386,10 @@ export default function AgentConnectModal({
</Paragraph>
</div>
<AgentConnectTabs endpoint={endpoint} />
<AgentConnectTabs
endpoint={endpoint}
providerIds={providers.map((p) => p.providerId)}
/>
</ModalContent>
</Modal>
);
+206 -12
View File
@@ -1,6 +1,7 @@
"use client";
import ButtonGroup from "@components/ButtonGroup";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon";
import { DataTable } from "@components/table/DataTable";
@@ -16,6 +17,7 @@ import {
Tooltip,
} from "chart.js";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import { ActivityIcon, ExternalLinkIcon } from "lucide-react";
import * as React from "react";
@@ -42,6 +44,18 @@ type DayBucket = {
input: number;
output: number;
cost: number;
// Prompt-cache accounting: read/write token buckets and the cache share of cost.
cacheRead: number;
cacheWrite: number;
cacheCost: number;
// Per-bucket cost split, so the Cost hover can break the day down the same
// way the access log breaks down a request. Undefined on older management
// servers that send only the two cost aggregates — undefined and zero mean
// different things here, so these stay optional.
inputCost?: number;
outputCost?: number;
cacheReadCost?: number;
cacheWriteCost?: number;
};
// AgentOverviewPanel shows account consumption over time as a per-day bar
@@ -206,13 +220,43 @@ function DailyBreakdownTable({ daily }: { daily: DayBucket[] }) {
},
{
id: "total",
accessorFn: (row) => row.input + row.output,
accessorFn: (row) =>
row.input + row.output + row.cacheRead + row.cacheWrite,
header: ({ column }) => (
<DataTableHeader column={column}>Total Tokens</DataTableHeader>
),
cell: ({ row }) => (
<NumberCell value={row.original.input + row.original.output} />
),
// Total includes the additive prompt-cache buckets; hover breaks them out.
cell: ({ row }) => {
const d = row.original;
const total = d.input + d.output + d.cacheRead + d.cacheWrite;
return (
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1"}>
<BreakdownRow
value={d.input.toLocaleString()}
label={"input"}
/>
<BreakdownRow
value={d.output.toLocaleString()}
label={"output"}
/>
<BreakdownRow
value={d.cacheRead.toLocaleString()}
label={"cache read"}
/>
<BreakdownRow
value={d.cacheWrite.toLocaleString()}
label={"cache write"}
/>
<BreakdownTotal value={total.toLocaleString()} />
</div>
}
>
<NumberCell value={total} />
</FullTooltip>
);
},
},
{
id: "cost",
@@ -220,13 +264,74 @@ function DailyBreakdownTable({ daily }: { daily: DayBucket[] }) {
header: ({ column }) => (
<DataTableHeader column={column}>Cost</DataTableHeader>
),
cell: ({ row }) => (
<span
className={"text-nb-gray-300 px-3 py-2 font-mono whitespace-nowrap"}
>
${row.original.cost.toFixed(2)}
</span>
),
// Mirrors the access log's Cost hover: one row per bucket the provider
// bills separately when the server sends the split, otherwise the coarse
// cache-vs-rest split derivable from the two aggregates. The shapes are
// told apart by whether inputCost is defined, not by whether it is zero.
cell: ({ row }) => {
const d = row.original;
const hasBreakdown =
d.inputCost !== undefined || d.outputCost !== undefined;
const display = (
<span
className={
"text-nb-gray-300 px-3 py-2 font-mono whitespace-nowrap"
}
>
${d.cost.toFixed(2)}
</span>
);
// Nothing to break out: no cache spend and no per-bucket split.
if (d.cacheCost <= 0 && !hasBreakdown) return display;
return (
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1 font-mono"}>
{hasBreakdown ? (
<>
<BreakdownRow
mono={true}
value={usd(d.inputCost ?? 0)}
label={"input"}
/>
<BreakdownRow
mono={true}
value={usd(d.outputCost ?? 0)}
label={"output"}
/>
<BreakdownRow
mono={true}
value={usd(d.cacheReadCost ?? 0)}
label={"cache read"}
/>
<BreakdownRow
mono={true}
value={usd(d.cacheWriteCost ?? 0)}
label={"cache write"}
/>
</>
) : (
<>
<BreakdownRow
mono={true}
value={usd(d.cost - d.cacheCost)}
label={"input + output"}
/>
<BreakdownRow
mono={true}
value={usd(d.cacheCost)}
label={"cache"}
/>
</>
)}
<BreakdownTotal mono={true} value={usd(d.cost)} />
</div>
}
>
{display}
</FullTooltip>
);
},
},
],
[],
@@ -280,6 +385,59 @@ function NumberCell({ value }: { value: number }) {
);
}
// The two hover breakdowns below follow the access-log table's Tokens and Cost
// hovers: one "<value> <label>" row per bucket, then the day's aggregate on a
// separated total row. Buckets are listed even when zero — a zero cache-read
// line says the day never hit the cache, and a fixed set of rows keeps the
// hover comparable between days.
// usd renders a breakdown amount at the precision the router meters at. The
// Cost column rounds a whole day to cents, but the hover is there for the
// detail — prompt-cache reads in particular are routinely sub-cent.
function usd(amount: number): string {
return `$${amount.toFixed(4)}`;
}
// mono keeps cost figures aligned digit-for-digit; labels stay in the body face
// so they don't read as data.
function BreakdownRow({
value,
label,
mono = false,
}: {
value: string;
label: string;
mono?: boolean;
}) {
return (
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{value}</span>
<span className={cn("text-nb-gray-400", mono && "font-sans")}>
{label}
</span>
</div>
);
}
function BreakdownTotal({
value,
mono = false,
}: {
value: string;
mono?: boolean;
}) {
return (
<div
className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
}
>
<span className={"font-medium text-nb-gray-200"}>{value}</span>
<span className={cn(mono && "font-sans")}>total</span>
</div>
);
}
function ConsumptionByDayChart({
daily,
metric,
@@ -340,6 +498,25 @@ function ConsumptionByDayChart({
: `${ctx.dataset.label}: ${Number(
ctx.parsed.y,
).toLocaleString()}`,
// Surface the day's prompt-cache share on hover without adding chart series.
// Gate on the figure this metric actually shows: a day with cache
// tokens but no cache spend (unpriced model) would otherwise
// render "Cache: $0.00", implying a priced-but-free day.
afterBody: (items) => {
const d = daily[items[0]?.dataIndex ?? -1];
if (!d) return [];
if (metric === "cost") {
return d.cacheCost > 0
? [`Cache: $${d.cacheCost.toFixed(2)}`]
: [];
}
return d.cacheRead + d.cacheWrite > 0
? [
`Cache read: ${d.cacheRead.toLocaleString()}`,
`Cache write: ${d.cacheWrite.toLocaleString()}`,
]
: [];
},
},
},
},
@@ -394,6 +571,13 @@ function toDailyBuckets(buckets: APIAgentNetworkUsageBucket[]): DayBucket[] {
input: b.input_tokens ?? 0,
output: b.output_tokens ?? 0,
cost: b.cost_usd ?? 0,
cacheRead: b.cached_input_tokens ?? 0,
cacheWrite: b.cache_creation_tokens ?? 0,
cacheCost: b.cache_cost_usd ?? 0,
inputCost: b.input_cost_usd,
outputCost: b.output_cost_usd,
cacheReadCost: b.cached_input_cost_usd,
cacheWriteCost: b.cache_creation_cost_usd,
});
}
@@ -406,7 +590,17 @@ function toDailyBuckets(buckets: APIAgentNetworkUsageBucket[]): DayBucket[] {
// Cap at a year of days as a defensive bound against a bad range.
for (let i = 0; i < 366 && !cur.isAfter(end); i++) {
const key = cur.format("YYYY-MM-DD");
out.push(map.get(key) ?? { key, input: 0, output: 0, cost: 0 });
out.push(
map.get(key) ?? {
key,
input: 0,
output: 0,
cost: 0,
cacheRead: 0,
cacheWrite: 0,
cacheCost: 0,
},
);
cur = cur.add(1, "day");
}
return out;
@@ -24,6 +24,17 @@ export type APIAgentNetworkAccessLog = {
output_tokens: number;
total_tokens: number;
cost_usd: number;
// Prompt-cache accounting. Optional — absent on older management servers.
cached_input_tokens?: number;
cache_creation_tokens?: number;
cache_cost_usd?: number;
// Per-bucket cost breakdown — the base of the cost fields; cost_usd is their
// sum and cache_cost_usd the cache pair. Optional — absent on older
// management servers, where only the two aggregates are available.
input_cost_usd?: number;
cached_input_cost_usd?: number;
cache_creation_cost_usd?: number;
output_cost_usd?: number;
user_id?: string;
source_ip?: string;
method?: string;
@@ -63,6 +74,16 @@ export type APIAgentNetworkAccessLogSession = {
output_tokens: number;
total_tokens: number;
cost_usd: number;
cached_input_tokens?: number;
cache_creation_tokens?: number;
cache_cost_usd?: number;
// Per-bucket cost breakdown — the base of the cost fields; cost_usd is their
// sum and cache_cost_usd the cache pair. Optional — absent on older
// management servers, where only the two aggregates are available.
input_cost_usd?: number;
cached_input_cost_usd?: number;
cache_creation_cost_usd?: number;
output_cost_usd?: number;
providers?: string[];
models?: string[];
decision: string;
@@ -77,6 +98,16 @@ export type APIAgentNetworkUsageBucket = {
output_tokens: number;
total_tokens: number;
cost_usd: number;
cached_input_tokens?: number;
cache_creation_tokens?: number;
cache_cost_usd?: number;
// Per-bucket cost breakdown — the base of the cost fields; cost_usd is their
// sum and cache_cost_usd the cache pair. Optional — absent on older
// management servers, where only the two aggregates are available.
input_cost_usd?: number;
cached_input_cost_usd?: number;
cache_creation_cost_usd?: number;
output_cost_usd?: number;
};
export type UsageGranularity = "day" | "week" | "month";
@@ -132,6 +163,7 @@ const KNOWN_PROVIDER_IDS: AIProviderId[] = [
"bedrock_api",
"vertex_ai_api",
"mistral_api",
"kimi_api",
"custom",
];
@@ -184,7 +216,14 @@ export function accessLogFromAgentAPI(
model: entry.model ?? "",
inputTokens: entry.input_tokens ?? 0,
outputTokens: entry.output_tokens ?? 0,
cachedInputTokens: entry.cached_input_tokens ?? 0,
cacheCreationTokens: entry.cache_creation_tokens ?? 0,
costUsd: entry.cost_usd ?? 0,
cacheCostUsd: entry.cache_cost_usd ?? 0,
inputCostUsd: entry.input_cost_usd,
cachedInputCostUsd: entry.cached_input_cost_usd,
cacheCreationCostUsd: entry.cache_creation_cost_usd,
outputCostUsd: entry.output_cost_usd,
durationMs: entry.duration_ms,
decision,
denyReason: decision === "deny" ? entry.deny_reason : undefined,
@@ -227,7 +266,14 @@ export function accessLogSessionFromAgentAPI(
inputTokens: session.input_tokens ?? 0,
outputTokens: session.output_tokens ?? 0,
totalTokens: session.total_tokens ?? 0,
cachedInputTokens: session.cached_input_tokens ?? 0,
cacheCreationTokens: session.cache_creation_tokens ?? 0,
costUsd: session.cost_usd ?? 0,
cacheCostUsd: session.cache_cost_usd ?? 0,
inputCostUsd: session.input_cost_usd,
cachedInputCostUsd: session.cached_input_cost_usd,
cacheCreationCostUsd: session.cache_creation_cost_usd,
outputCostUsd: session.output_cost_usd,
providers: session.providers ?? [],
models: session.models ?? [],
decision,
@@ -12,6 +12,7 @@ export type AIProviderId =
| "bedrock_api"
| "vertex_ai_api"
| "mistral_api"
| "kimi_api"
| "litellm_proxy"
| "portkey"
| "bifrost"
@@ -64,6 +65,9 @@ export type AIProvider = {
// Skip TLS certificate verification on upstream requests — for custom
// providers using self-signed certs. Off by default.
skipTlsVerification?: boolean;
// Disable identity metadata injection (caller user + authorizing group) on
// upstream requests, e.g. AWS Bedrock cost-allocation. Off by default.
metadataDisabled?: boolean;
status: AIProviderStatus;
models: ProviderModel[];
allowedGroups: string[];
@@ -171,7 +175,17 @@ export type AIAccessLogEntry = {
model: string;
inputTokens: number;
outputTokens: number;
// Prompt-cache buckets: read/write token counts and the cache share of costUsd. Optional — absent on older management servers.
cachedInputTokens?: number;
cacheCreationTokens?: number;
costUsd: number;
cacheCostUsd?: number;
// Per-bucket cost breakdown. Undefined (not 0) when the server predates the
// breakdown, so the UI can tell "no split available" from "split is zero".
inputCostUsd?: number;
cachedInputCostUsd?: number;
cacheCreationCostUsd?: number;
outputCostUsd?: number;
durationMs: number;
decision: AIAccessLogDecision;
denyReason?: string;
@@ -209,7 +223,16 @@ export type AIAccessLogSession = {
inputTokens: number;
outputTokens: number;
totalTokens: number;
cachedInputTokens?: number;
cacheCreationTokens?: number;
costUsd: number;
cacheCostUsd?: number;
// Per-bucket cost breakdown. Undefined (not 0) when the server predates the
// breakdown, so the UI can tell "no split available" from "split is zero".
inputCostUsd?: number;
cachedInputCostUsd?: number;
cacheCreationCostUsd?: number;
outputCostUsd?: number;
providers: string[]; // distinct vendor ids seen in the session
models: string[]; // distinct models seen in the session
decision: AIAccessLogDecision;
@@ -169,8 +169,11 @@ export default function AgentProvidersTable({
learnMore={
<>
Learn more about
<InlineLink href={"https://docs.netbird.io/"} target={"_blank"}>
Agent Network
<InlineLink
href={"https://docs.netbird.io/agent-network/providers"}
target={"_blank"}
>
Agent Network Providers
<ExternalLinkIcon size={12} />
</InlineLink>
</>
Loaded 20 of 34 files, more files were not shown because too many files have changed in this diff. Show more