Compare commits

..
2 Commits
Author SHA1 Message Date
Misha Bragin b66cc4ccb7 Cache pricing + custom model entry (#736) 2026-08-01 15:56:24 +02:00
Zoltan Papp 1d408f2e7e Add openSUSE zypper instructions with GPG key import to the Linux tab (#735)
Zypper ignores the gpgkey= entry of a .repo file, so the yum snippet would
leave the repository key uninstalled on openSUSE. The new distro entry adds
the repository with GPG checking enabled and imports the key on refresh.
2026-07-28 17:50:09 +02:00
12 changed files with 649 additions and 184 deletions

No files matched your search

+73 -4
View File
@@ -72,13 +72,19 @@ function mockAccounts(page: Page, state: AccountState, delayMs: number) {
async function openOnboarding(
browser: Browser,
opts: { source?: boolean; account: AccountState; delayMs?: number },
opts: {
source?: boolean;
account: AccountState;
delayMs?: number;
edition?: string;
expectOnboarding?: boolean;
},
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([sourceKey, sourceValue, withSource]) => {
([sourceKey, sourceValue, withSource, edition]) => {
try {
window.localStorage.setItem("netbird-test-onboarding", "true");
if (withSource) {
@@ -87,13 +93,32 @@ async function openOnboarding(
sourceValue as string,
);
}
if (edition) {
window.localStorage.setItem("netbird-test-edition", edition as string);
}
} catch (e) {}
},
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
[
SIGNUP_SOURCE_KEY,
AGENT_NETWORK_SOURCE,
!!opts.source,
opts.edition ?? "",
] as const,
);
const page = await context.newPage();
mockAccounts(page, opts.account, opts.delayMs ?? 0);
await loginToApp(page, "owner", { expectOnboarding: true });
// The mocked GET /accounts is what drives the onboarding decision, and the
// dashboard can resolve before it lands — a "no form opened" assertion would
// then pass vacuously. Wait for the rewritten response before returning.
const accountsLoaded = page.waitForResponse(
(resp) =>
/\/api\/accounts(\?|$)/.test(resp.url()) &&
resp.request().method() === "GET",
);
await loginToApp(page, "owner", {
expectOnboarding: opts.expectOnboarding ?? true,
});
await accountsLoaded;
return { page, close: () => context.close() };
}
@@ -143,6 +168,50 @@ test.describe.serial("Onboarding form selection @onboarding", () => {
}
});
test("a self-hosted account with onboarding pending shows the regular flow at the intent step", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { onboardingFlowPending: true },
});
try {
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
// The signup survey relies on a JWT domain claim self-hosted IdPs don't
// emit, so the flow skips it and opens on the intent step. Scoped to the
// form: the dashboard behind the modal has its own "Get Started with
// NetBird" heading, and getByText matches case-insensitively.
await expect(
page
.getByTestId(REGULAR_FORM)
.getByRole("heading", { name: "Get started with NetBird" }),
).toBeVisible();
} finally {
await close();
}
});
test("a self-hosted account with only the signup form pending shows no onboarding", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { signupFormPending: true },
expectOnboarding: false,
});
try {
// loginToApp resolved the dashboard, so the account state has been
// applied — neither flow should have opened.
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("a slow backend never flashes the regular form for a netbird.ai signup", async ({
browser,
}) => {
+8
View File
@@ -128,6 +128,14 @@ async function openRowActions(
name: string,
) {
await clearScrollLock(page);
// A force-click can open a new action menu without the previous one having
// closed (scroll-lock artifacts suppress Radix's outside-click dismissal),
// leaving two menus open — the item lookup then hits a strict-mode violation.
// Dismiss any open menu and wait for it to be gone before opening the next.
if (await page.locator('[role="menu"]').count()) {
await page.keyboard.press("Escape");
await expect(page.locator('[role="menu"]')).toHaveCount(0);
}
await page
.locator("tr")
.filter({ hasText: name })
+19
View File
@@ -69,6 +69,25 @@ test.describe("Setup modal Linux distro instructions", () => {
);
});
test("imports the repository GPG key on openSUSE", async ({ page }) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "openSUSE (Zypper)");
await expect(modal).toContainText(
"sudo zypper --non-interactive addrepo -f -g https://pkgs.netbird.io/yum/ netbird",
);
await expect(modal).toContainText(
"sudo zypper --gpg-auto-import-keys refresh netbird",
);
await expect(modal).toContainText("sudo zypper install netbird");
await expect(modal).toContainText(
"sudo zypper install netbird-ui libgtk-4-1 libwebkitgtk-6_0-4 xdg-utils",
);
// Zypper ignores gpgkey= in a .repo file, so that snippet must not appear
await expect(modal).not.toContainText("/etc/yum.repos.d/netbird.repo");
});
test("offers CLI only on Amazon Linux", async ({ page }) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
+8
View File
@@ -22,7 +22,15 @@ test.describe.serial("Team - Service Users @team", () => {
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
await page.locator("tr").getByText(regularUser).click();
await changeRoleTo(page, "Admin");
// Await the PUT so the role change is persisted before the next serial
// test asserts it — clicking save alone returns before the request lands.
const saveResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/api/users/") && resp.request().method() === "PUT",
{ timeout: 30_000 },
);
await page.getByTestId("save-changes").click();
await saveResponse;
// Create and delete access token
const tokenName = generateRandomName("tkn_");
+2 -2
View File
@@ -57,9 +57,9 @@ export default function DataTableHeader({
{children}
{sorting &&
(column.getIsSorted() === "desc" ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
) : (
<IconSortAscending size={16} />
))}
</div>
</FullTooltip>
File diff suppressed because it is too large. Load diff
@@ -27,6 +27,13 @@ export type APIProviderModel = {
id: string;
input_per_1k: number;
output_per_1k: number;
// Optional cache rates. Omitted on the wire = inherit NetBird's
// default rate for this model (the backend folds it in at synthesis
// time); explicit 0 = no discount (bucket bills at the input rate).
// Never coerce undefined to 0 — the two mean different things.
cached_input_per_1k?: number;
cache_read_per_1k?: number;
cache_creation_per_1k?: number;
};
export type APIProvider = {
@@ -156,6 +163,9 @@ function fromAPI(p: APIProvider): AIProvider {
id: m.id,
inputPer1k: m.input_per_1k,
outputPer1k: m.output_per_1k,
cachedInputPer1k: m.cached_input_per_1k,
cacheReadPer1k: m.cache_read_per_1k,
cacheCreationPer1k: m.cache_creation_per_1k,
}));
return {
id: p.id,
@@ -191,10 +201,16 @@ function fromAPI(p: APIProvider): AIProvider {
}
function toAPIModels(models: ProviderModel[]): APIProviderModel[] {
// undefined cache rates stay undefined so JSON.stringify omits the
// key: an omitted rate inherits NetBird's default, an explicit 0
// disables the discount. Coercing here would change billing.
return models.map((m) => ({
id: m.id,
input_per_1k: m.inputPer1k,
output_per_1k: m.outputPer1k,
cached_input_per_1k: m.cachedInputPer1k,
cache_read_per_1k: m.cacheReadPer1k,
cache_creation_per_1k: m.cacheCreationPer1k,
}));
}
+163 -115
View File
@@ -445,12 +445,13 @@ export default function AgentAccessLogTable({
</DataTableHeader>
),
// Same Reason cell as the flat view (deny reason, or the authorising
// policy link). A session is one policy decision in practice; surface a
// denied request when present, otherwise the first entry.
// policy link). Prefer an allowed request so a session that succeeded
// shows the authorising policy rather than an incidental deny reason;
// only surface a deny reason when every request was denied.
cell: ({ row }) => {
const entries = row.original.entries;
const representative =
entries.find((e) => e.decision === "deny") ?? entries[0];
entries.find((e) => e.decision === "allow") ?? entries[0];
return representative ? (
<ReasonCell entry={representative} />
) : (
@@ -996,13 +997,10 @@ function ProviderCell({
);
}
function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
if (
(entry.inputTokens === undefined || entry.inputTokens === 0) &&
(entry.outputTokens === undefined || entry.outputTokens === 0)
) {
return <EmptyRow />;
}
// TokenBreakdown is the hover content shared by the flat Tokens column and the
// session's per-request rows: one line per token bucket plus a total. Buckets
// default to 0 so it renders for denied requests that carry partial counts.
function TokenBreakdown({ entry }: { entry: AIAccessLogEntry }) {
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.
@@ -1012,54 +1010,66 @@ function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
cacheRead +
cacheWrite;
return (
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1"}>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<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"}>
<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={"font-medium text-nb-gray-200"}>
{total.toLocaleString()}
</span>
<span>total</span>
</div>
</div>
}
>
<div className={"text-xs flex flex-col gap-1"}>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>
{(entry.inputTokens ?? 0).toLocaleString()}
</span>
<span className={"text-nb-gray-400"}>input</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>
{(entry.outputTokens ?? 0).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={"font-medium text-nb-gray-200"}>
{total.toLocaleString()}
</span>
<span>total</span>
</div>
</div>
);
}
function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
// Cache-only requests carry no input/output but real cache read/write tokens,
// so weigh all four buckets — matching TokenBreakdown's total.
if (
(entry.inputTokens ?? 0) === 0 &&
(entry.outputTokens ?? 0) === 0 &&
(entry.cachedInputTokens ?? 0) === 0 &&
(entry.cacheCreationTokens ?? 0) === 0
) {
return <EmptyRow />;
}
return (
<FullTooltip content={<TokenBreakdown entry={entry} />}>
<div
className={"flex flex-col text-xs gap-1 text-nb-gray-300 font-medium"}
>
<div className={"flex gap-2 items-center whitespace-nowrap"}>
<ArrowUpIcon size={15} className={"text-sky-400"} />
<span className={"sr-only"}>Input:</span>
{entry.inputTokens.toLocaleString()}
{(entry.inputTokens ?? 0).toLocaleString()}
</div>
<div className={"flex gap-2 items-center whitespace-nowrap"}>
<ArrowDownIcon size={15} className={"text-netbird"} />
<span className={"sr-only"}>Output:</span>
{entry.outputTokens.toLocaleString()}
{(entry.outputTokens ?? 0).toLocaleString()}
</div>
</div>
</FullTooltip>
@@ -1076,32 +1086,89 @@ function CostRow({ amount, label }: { amount: number; label: string }) {
);
}
// 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,
}: {
type CostFields = {
costUsd: number;
cacheCostUsd?: number;
inputCostUsd?: number;
cachedInputCostUsd?: number;
cacheCreationCostUsd?: number;
outputCostUsd?: number;
}) {
};
// hasCostBreakdown reports whether a request carries enough cost detail to be
// worth a hover: a cache split or a per-bucket breakdown. Shared so the flat
// Cost cell and the session rows attach the tooltip on the same condition.
function hasCostBreakdown(f: CostFields): boolean {
const cache = f.cacheCostUsd ?? 0;
const perBucket = f.inputCostUsd !== undefined || f.outputCostUsd !== undefined;
return cache > 0 || perBucket;
}
// CostBreakdown is the hover content shared by the flat Cost column and the
// session's per-request rows.
//
// 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 CostBreakdown({
costUsd,
cacheCostUsd,
inputCostUsd,
cachedInputCostUsd,
cacheCreationCostUsd,
outputCostUsd,
}: CostFields) {
const cache = cacheCostUsd ?? 0;
const hasBreakdown =
inputCostUsd !== undefined || outputCostUsd !== undefined;
const cacheRead = cachedInputCostUsd ?? 0;
const cacheWrite = cacheCreationCostUsd ?? 0;
return (
<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>
);
}
// CostCell renders the metered USD cost with a hover breakdown of the buckets
// it was billed from (see CostBreakdown).
function CostCell(fields: CostFields) {
const {
costUsd,
cacheCostUsd,
inputCostUsd,
cachedInputCostUsd,
cacheCreationCostUsd,
outputCostUsd,
} = fields;
const cache = cacheCostUsd ?? 0;
// 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
@@ -1127,45 +1194,10 @@ function CostCell({
</span>
);
// Nothing to break out: no cache spend and no per-bucket split to show.
if (cache <= 0 && !hasBreakdown) return display;
if (!hasCostBreakdown(fields)) 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>
<FullTooltip content={<CostBreakdown {...fields} />}>{display}</FullTooltip>
);
}
@@ -1323,7 +1355,13 @@ function SessionEntriesRow({ session }: { session: AIAccessLogSession }) {
>
{[...session.entries].reverse().map((entry) => {
const isOpen = open.includes(entry.id);
const total = (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
// Sum all four buckets so cache-only requests count and the figure
// matches TokenBreakdown's total.
const total =
(entry.inputTokens ?? 0) +
(entry.outputTokens ?? 0) +
(entry.cachedInputTokens ?? 0) +
(entry.cacheCreationTokens ?? 0);
const isError = entry.decision === "deny" || entry.status >= 400;
return (
<div key={entry.id}>
@@ -1396,20 +1434,30 @@ function SessionEntriesRow({ session }: { session: AIAccessLogSession }) {
>
{entry.model || "—"}
</code>
<span
className={
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[110px] text-right shrink-0"
}
<FullTooltip
disabled={total === 0}
content={<TokenBreakdown entry={entry} />}
>
{total.toLocaleString()} tokens
</span>
<span
className={
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[64px] text-right shrink-0"
}
<span
className={
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[110px] text-right shrink-0"
}
>
{total.toLocaleString()} tokens
</span>
</FullTooltip>
<FullTooltip
disabled={!hasCostBreakdown(entry)}
content={<CostBreakdown {...entry} />}
>
${entry.costUsd.toFixed(4)}
</span>
<span
className={
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[64px] text-right shrink-0"
}
>
${entry.costUsd.toFixed(4)}
</span>
</FullTooltip>
</button>
{isOpen && (
<div className={"border-t border-nb-gray-800/70"}>
@@ -43,6 +43,17 @@ export type ProviderModel = {
id: string;
inputPer1k: number;
outputPer1k: number;
// Optional prompt-cache rates (USD per 1k tokens). undefined means
// "inherit NetBird's default rate for this model" — the backend folds
// the default in at synthesis time; an explicit 0 means "no discount,
// bill this cache bucket at the input rate". Keep undefined distinct
// from 0 when round-tripping.
// OpenAI shape: cached prompt tokens (a subset of input tokens).
cachedInputPer1k?: number;
// Anthropic shape: the two additive cache buckets (read ≈0.1x input,
// creation ≈1.25x input).
cacheReadPer1k?: number;
cacheCreationPer1k?: number;
};
export type AIProvider = {
@@ -8,6 +8,12 @@ export type CatalogModel = {
label: string;
input_per_1k: number;
output_per_1k: number;
// Default cache rates, present only when the model has one. Used to
// prefill the model row so the operator sees (and can override) the
// rate NetBird would bill with.
cached_input_per_1k?: number;
cache_read_per_1k?: number;
cache_creation_per_1k?: number;
context_window: number;
};
@@ -42,6 +48,13 @@ export type CatalogProvider = {
// can choose between the always-on x-bf-lh- log family and the
// declared x-bf-dim- telemetry family.
identity_injection?: CatalogIdentityInjection;
// pricing_surfaces names the cost-meter surfaces this provider's
// traffic is metered under ("openai", "anthropic", "bedrock"). The
// modal uses it to decide which cache-rate fields apply: "openai" →
// cached input (subset discount); "anthropic"/"bedrock" → cache read
// + cache write (additive buckets). Absent for gateway/custom entries
// — show every cache field for those.
pricing_surfaces?: string[];
models: CatalogModel[];
};
@@ -120,7 +120,10 @@ export const OnboardingProvider = ({
// deciding, so a slow mode fetch can't briefly show the regular form to an
// account that turns out to be Agent Network-only via config.
if (agentNetworkModeLoading) return false;
if (!isNetBirdCloud()) return false;
// The regular flow shows on both cloud and self-hosted, but the signup
// survey relies on a JWT domain claim self-hosted IdPs don't emit, so it
// only counts toward showing (and is only rendered) on cloud — self-hosted
// starts directly at the intent step.
const isSignupFormPending = isNetBirdCloud()
? !!account?.onboarding?.signup_form_pending
: false;
@@ -95,6 +95,21 @@ const DISTROS: Distro[] = [
],
note: "The desktop app needs version 10 or newer with EPEL enabled, which provides WebKitGTK 6.0. On version 9 install the CLI only.",
},
{
label: "openSUSE (Zypper)",
value: "opensuse",
// Zypper does not read gpgkey= from a .repo file, so the repository key is
// imported by an explicit refresh instead of the YUM_REPOSITORY snippet.
repository: [
"sudo zypper --non-interactive addrepo -f -g https://pkgs.netbird.io/yum/ netbird",
"sudo zypper --gpg-auto-import-keys refresh netbird",
],
cli: "sudo zypper install netbird",
desktopApp: [
"sudo zypper install netbird-ui libgtk-4-1 libwebkitgtk-6_0-4 xdg-utils",
],
note: "The desktop app needs Tumbleweed or Leap 15.6 and newer. On earlier releases install the CLI only.",
},
{
label: "Amazon Linux (YUM)",
value: "amazon",