diff --git a/e2e/tests/onboarding-form.spec.ts b/e2e/tests/onboarding-form.spec.ts index cdcfb6ed8..57e9aee0b 100644 --- a/e2e/tests/onboarding-form.spec.ts +++ b/e2e/tests/onboarding-form.spec.ts @@ -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 }> { 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, }) => { diff --git a/e2e/tests/setup-keys.spec.ts b/e2e/tests/setup-keys.spec.ts index b6b1376bb..1de8db6d9 100644 --- a/e2e/tests/setup-keys.spec.ts +++ b/e2e/tests/setup-keys.spec.ts @@ -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 }) diff --git a/e2e/tests/team-service-users.spec.ts b/e2e/tests/team-service-users.spec.ts index a2c422e94..201b7050c 100644 --- a/e2e/tests/team-service-users.spec.ts +++ b/e2e/tests/team-service-users.spec.ts @@ -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_"); diff --git a/src/components/table/DataTableHeader.tsx b/src/components/table/DataTableHeader.tsx index aea54ee56..91aeb876a 100644 --- a/src/components/table/DataTableHeader.tsx +++ b/src/components/table/DataTableHeader.tsx @@ -57,9 +57,9 @@ export default function DataTableHeader({ {children} {sorting && (column.getIsSorted() === "desc" ? ( - - ) : ( + ) : ( + ))} diff --git a/src/modules/agent-network/AIProviderModal.tsx b/src/modules/agent-network/AIProviderModal.tsx index b4f8be3af..ba6391244 100644 --- a/src/modules/agent-network/AIProviderModal.tsx +++ b/src/modules/agent-network/AIProviderModal.tsx @@ -23,8 +23,10 @@ import { AlertCircleIcon, ArrowRightLeft, Boxes, + ChevronRightIcon, ExternalLinkIcon, KeyRound, + ListIcon, MinusCircleIcon, PlusCircle, PlusIcon, @@ -149,6 +151,20 @@ type Props = { provider?: AIProvider; }; +// ModelRowEditor owns row-local UI state (custom/catalog mode, expanded cache +// disclosure, in-progress price text). Keying the row list by array index would +// let that state stick to a position rather than a row, so removing or +// reordering a row would leak the removed row's state into its neighbour. Each +// row carries a stable client-only key instead. _key is never sent to the API — +// toAPIModels whitelists the wire fields. +type EditableModel = ProviderModel & { _key: string }; + +let modelKeySeq = 0; +const withModelKey = (m: ProviderModel): EditableModel => ({ + ...m, + _key: `model-${modelKeySeq++}`, +}); + export default function AIProviderModal({ open, onOpenChange, @@ -178,7 +194,9 @@ export default function AIProviderModal({ ); const [apiKey, setApiKey] = useState(isEdit ? "••••••••" : ""); const [bootstrapCluster, setBootstrapCluster] = useState(""); - const [models, setModels] = useState(provider?.models ?? []); + const [models, setModels] = useState(() => + (provider?.models ?? []).map(withModelKey), + ); // Vertex AI authenticates with a service-account JSON key, not an API key. // We upload the file and store it base64-encoded in apiKey (the server @@ -341,7 +359,7 @@ export default function AIProviderModal({ setUpstreamUrl(provider.upstreamUrl); setApiKey("••••••••"); setBootstrapCluster(""); - setModels(provider.models); + setModels(provider.models.map(withModelKey)); setExtraValues(provider.extraValues ?? {}); setIdentityHeaderUserId(provider.identityHeaderUserId ?? ""); setIdentityHeaderGroups(provider.identityHeaderGroups ?? ""); @@ -393,6 +411,21 @@ export default function AIProviderModal({ const handleSubmit = async () => { if (!catalog) return; + // Drop rows the operator never filled in (an added-but-empty custom + // row, or the empty fallback row when the catalog is exhausted) — + // the API rejects models without an id, which would fail the whole + // save over a leftover blank line. Duplicate ids are collapsed to the + // first row too: the catalog dropdown can't offer an id twice, but two + // custom rows can be typed with the same id, and shipping both would + // send an ambiguous price for the model. + const seenModelIds = new Set(); + const submittedModels = models + .map((m) => ({ ...m, id: m.id.trim() })) + .filter((m) => { + if (m.id === "" || seenModelIds.has(m.id)) return false; + seenModelIds.add(m.id); + return true; + }); // Identity overrides are only forwarded when the catalog entry // flags either shape (HeaderPair or JSONMetadata) as customizable. // Sending them on a non-customizable provider would be a no-op @@ -409,7 +442,7 @@ export default function AIProviderModal({ providerId, name, upstreamUrl, - models, + models: submittedModels, extraValues: sanitizedExtraValues, ...identityOverrides, skipTlsVerification: isCustomKind ? skipTlsVerification : false, @@ -430,7 +463,7 @@ export default function AIProviderModal({ ...identityOverrides, skipTlsVerification: isCustomKind ? skipTlsVerification : false, metadataDisabled, - models, + models: submittedModels, enabled: true, }); handleClose(); @@ -489,18 +522,38 @@ export default function AIProviderModal({ if (next) { setModels((prev) => [ ...prev, - { + withModelKey({ id: next.id, inputPer1k: next.input_per_1k, outputPer1k: next.output_per_1k, - }, + cachedInputPer1k: next.cached_input_per_1k, + cacheReadPer1k: next.cache_read_per_1k, + cacheCreationPer1k: next.cache_creation_per_1k, + }), ]); return; } // No catalog match left — append an empty row the operator can fill. - setModels((prev) => [...prev, { id: "", inputPer1k: 0, outputPer1k: 0 }]); + setModels((prev) => [ + ...prev, + withModelKey({ id: "", inputPer1k: 0, outputPer1k: 0 }), + ]); }; + // Which cache-rate fields apply to this provider's models, derived + // from the catalog's pricing surfaces: "openai" bills cached prompt + // tokens as a discounted SUBSET of input (one rate), "anthropic" / + // "bedrock" bill two ADDITIVE buckets (cache read + cache write). + // Gateways/custom entries (and older backends) declare no surfaces — + // NetBird can't know the upstream shape, so every field is offered. + const pricingSurfaces = catalog?.pricing_surfaces ?? []; + const showCachedInputRate = + pricingSurfaces.length === 0 || pricingSurfaces.includes("openai"); + const showCacheBucketRates = + pricingSurfaces.length === 0 || + pricingSurfaces.includes("anthropic") || + pricingSurfaces.includes("bedrock"); + return ( (o ? null : handleClose())}> @@ -1212,13 +1265,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. + 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.map((row, idx) => ( updateModel(idx, { inputPer1k: n })} onChangeOutput={(n) => updateModel(idx, { outputPer1k: n })} + showCachedInputRate={showCachedInputRate} + showCacheBucketRates={showCacheBucketRates} + onChangeCachedInput={(n) => + updateModel(idx, { cachedInputPer1k: n }) + } + onChangeCacheRead={(n) => + updateModel(idx, { cacheReadPer1k: n }) + } + onChangeCacheCreation={(n) => + updateModel(idx, { cacheCreationPer1k: n }) + } onRemove={() => removeModel(idx)} /> ))} @@ -1370,11 +1439,20 @@ function FormRow({ ); } +// CUSTOM_MODEL_OPTION is the sentinel value of the "Custom model…" +// dropdown entry. Never a real model id (vendors don't use NUL-ish +// double-underscore namespacing), never sent to the API — selecting it +// only flips the row into free-text mode. +const CUSTOM_MODEL_OPTION = "__netbird_custom_model__"; + type CatalogModelOption = { id: string; label: string; input_per_1k: number; output_per_1k: number; + cached_input_per_1k?: number; + cache_read_per_1k?: number; + cache_creation_per_1k?: number; }; // priceToInput renders a stored price as an editable string, always using "." @@ -1389,6 +1467,61 @@ function priceFromInput(s: string): number { return parseFloat(s.replace(/,/g, ".")) || 0; } +// optionalPriceFromInput parses a price whose empty state is meaningful. It +// reports undefined for anything that isn't a number ("", "abc") instead of +// falling back to 0 — for cache rates an explicit 0 is a real setting ("bill +// cached tokens at the input rate"), so a typo must not silently become one. +function optionalPriceFromInput(s: string): number | undefined { + const t = s.trim(); + if (t === "") return undefined; + const n = parseFloat(t.replace(/,/g, ".")); + return Number.isFinite(n) ? n : undefined; +} + +// OptionalPriceField is a price input whose EMPTY state is meaningful: +// empty = undefined = "inherit NetBird's default rate for this model", +// while an explicit 0 disables the cache discount. It must never coerce +// one into the other, so it keeps its own string state and only reports +// undefined for a blank box. +function OptionalPriceField({ + label, + value, + onChange, +}: { + label: string; + value: number | undefined; + onChange: (n: number | undefined) => void; +}) { + const [str, setStr] = useState(() => + value === undefined ? "" : priceToInput(value), + ); + // Re-sync when the value is set from outside (catalog model pick), + // but not while the operator is mid-typing the same value. + useEffect(() => { + const parsed = optionalPriceFromInput(str); + if (parsed !== value) { + setStr(value === undefined ? "" : priceToInput(value)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + return ( +
+ + { + setStr(e.target.value); + onChange(optionalPriceFromInput(e.target.value)); + }} + /> +
+ ); +} + function ModelRowEditor({ row, catalogModels, @@ -1396,6 +1529,11 @@ function ModelRowEditor({ onChangeId, onChangeInput, onChangeOutput, + showCachedInputRate, + showCacheBucketRates, + onChangeCachedInput, + onChangeCacheRead, + onChangeCacheCreation, onRemove, }: { row: ProviderModel; @@ -1404,6 +1542,13 @@ function ModelRowEditor({ onChangeId: (id: string) => void; onChangeInput: (n: number) => void; onChangeOutput: (n: number) => void; + // Which cache-rate fields apply to this provider's billing shape; + // see the pricing_surfaces derivation in the modal body. + showCachedInputRate: boolean; + showCacheBucketRates: boolean; + onChangeCachedInput: (n: number | undefined) => void; + onChangeCacheRead: (n: number | undefined) => void; + onChangeCacheCreation: (n: number | undefined) => void; onRemove: () => void; }) { // Editable text for the price fields. We keep the raw string locally so the @@ -1434,75 +1579,185 @@ function ModelRowEditor({ // React will unmount the input and steal focus. const hasCatalog = catalogModels.length > 0; - // Catalog options excluding the ones already on other rows. The - // current row's own id stays in the list so the dropdown can render - // its label. + // Custom-model entry: catalog providers get a "Custom model…" option + // that swaps the dropdown for a free-text input, so operators can add + // models NetBird doesn't list yet (e.g. a model released after this + // build). Rows loaded with an id the catalog doesn't know start in + // custom mode so their id is editable rather than trapped in a + // single-option dropdown. + const [customMode, setCustomMode] = useState( + () => + hasCatalog && row.id !== "" && !catalogModels.some((m) => m.id === row.id), + ); + // The catalog is fetched async, so an edit-modal row can mount before it + // arrives — the initializer then sees no catalog and leaves customMode off, + // trapping an unknown id in a dropdown that can't represent it. Re-evaluate + // once the catalog lands. Keyed on hasCatalog only: later catalog/row churn + // must not undo the operator's own custom-mode choice. + useEffect(() => { + if (!hasCatalog || row.id === "") return; + if (!catalogModels.some((m) => m.id === row.id)) setCustomMode(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasCatalog]); + + // Catalog options excluding the ones already on other rows, plus the + // custom-model escape hatch. const dropdownOptions = useMemo(() => { const visible = catalogModels.filter( (m) => m.id === row.id || !usedIds.has(m.id), ); - const seen = new Set(visible.map((m) => m.id)); const opts = visible.map((m) => ({ value: m.id, label: m.label })); - if (row.id && !seen.has(row.id)) { - opts.unshift({ value: row.id, label: row.id }); - } + opts.push({ value: CUSTOM_MODEL_OPTION, label: "Custom model…" }); return opts; }, [catalogModels, usedIds, row.id]); + const showCacheLine = showCachedInputRate || showCacheBucketRates; + const hasCacheValues = + row.cachedInputPer1k !== undefined || + row.cacheReadPer1k !== undefined || + row.cacheCreationPer1k !== undefined; + // Cache rates live behind a per-row disclosure, collapsed by default: + // most operators keep NetBird's defaults, so the row stays compact. + // The collapsed summary ("· custom" vs "· default") signals when a + // row carries stored rates worth expanding. + const [cacheOpen, setCacheOpen] = useState(false); + return (
-
- - {hasCatalog ? ( - +
+ + {hasCatalog && !customMode ? ( + { + if (v === CUSTOM_MODEL_OPTION) { + setCustomMode(true); + onChangeId(""); + return; + } + onChangeId(v); + }} + options={dropdownOptions} + placeholder={"Select a model..."} + /> + ) : hasCatalog ? ( +
+ onChangeId(e.target.value)} + placeholder={"e.g. claude-fable-6"} + autoFocus={row.id === ""} + /> + +
+ ) : ( + onChangeId(e.target.value)} + placeholder={"e.g. gpt-4o-mini"} + /> + )} +
+
+ + { + setInputStr(e.target.value); + onChangeInput(priceFromInput(e.target.value)); + }} /> - ) : ( +
+
+ onChangeId(e.target.value)} - placeholder={"e.g. gpt-4o-mini"} + type={"text"} + inputMode={"decimal"} + value={outputStr} + onChange={(e) => { + setOutputStr(e.target.value); + onChangeOutput(priceFromInput(e.target.value)); + }} /> - )} -
-
- - { - setInputStr(e.target.value); - onChangeInput(priceFromInput(e.target.value)); - }} - /> -
-
- - { - setOutputStr(e.target.value); - onChangeOutput(priceFromInput(e.target.value)); - }} - /> +
+
- + {showCacheLine && ( + <> + + {cacheOpen && ( +
+ {showCachedInputRate && ( + + )} + {showCacheBucketRates && ( + <> + + + + )} +
+ )} + + )}
); } diff --git a/src/modules/agent-network/AIProvidersProvider.tsx b/src/modules/agent-network/AIProvidersProvider.tsx index 9ade21111..f04a17f72 100644 --- a/src/modules/agent-network/AIProvidersProvider.tsx +++ b/src/modules/agent-network/AIProvidersProvider.tsx @@ -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, })); } diff --git a/src/modules/agent-network/AgentAccessLogTable.tsx b/src/modules/agent-network/AgentAccessLogTable.tsx index 7d3f15df8..dab492296 100644 --- a/src/modules/agent-network/AgentAccessLogTable.tsx +++ b/src/modules/agent-network/AgentAccessLogTable.tsx @@ -445,12 +445,13 @@ export default function AgentAccessLogTable({ ), // 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 ? ( ) : ( @@ -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 ; - } +// 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 ( - -
- - {entry.inputTokens.toLocaleString()} - - input -
-
- - {entry.outputTokens.toLocaleString()} - - output -
-
- {cacheRead.toLocaleString()} - cache read -
-
- {cacheWrite.toLocaleString()} - cache write -
-
- - {total.toLocaleString()} - - total -
- - } - > +
+
+ + {(entry.inputTokens ?? 0).toLocaleString()} + + input +
+
+ + {(entry.outputTokens ?? 0).toLocaleString()} + + output +
+
+ {cacheRead.toLocaleString()} + cache read +
+
+ {cacheWrite.toLocaleString()} + cache write +
+
+ + {total.toLocaleString()} + + total +
+
+ ); +} + +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 ; + } + return ( + }>
Input: - {entry.inputTokens.toLocaleString()} + {(entry.inputTokens ?? 0).toLocaleString()}
Output: - {entry.outputTokens.toLocaleString()} + {(entry.outputTokens ?? 0).toLocaleString()}
@@ -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. +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 CostCell({ +function CostBreakdown({ costUsd, cacheCostUsd, inputCostUsd, cachedInputCostUsd, cacheCreationCostUsd, outputCostUsd, -}: { - costUsd: number; - cacheCostUsd?: number; - inputCostUsd?: number; - cachedInputCostUsd?: number; - cacheCreationCostUsd?: number; - outputCostUsd?: number; -}) { +}: CostFields) { const cache = cacheCostUsd ?? 0; const hasBreakdown = inputCostUsd !== undefined || outputCostUsd !== undefined; + const cacheRead = cachedInputCostUsd ?? 0; + const cacheWrite = cacheCreationCostUsd ?? 0; + return ( +
+ {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. */} + + + + + + ) : ( + <> + + + + )} +
+ + ${costUsd.toFixed(4)} + + total +
+
+ ); +} + +// 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({ ); // 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 ( - - {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. */} - - - - - - ) : ( - <> - - - - )} -
- - ${costUsd.toFixed(4)} - - total -
- - } - > - {display} -
+ }>{display} ); } @@ -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 (
@@ -1396,20 +1434,30 @@ function SessionEntriesRow({ session }: { session: AIAccessLogSession }) { > {entry.model || "—"} - } > - {total.toLocaleString()} tokens - - + {total.toLocaleString()} tokens + + + } > - ${entry.costUsd.toFixed(4)} - + + ${entry.costUsd.toFixed(4)} + + {isOpen && (
diff --git a/src/modules/agent-network/data/mockData.ts b/src/modules/agent-network/data/mockData.ts index 5756cdbad..d8ca91026 100644 --- a/src/modules/agent-network/data/mockData.ts +++ b/src/modules/agent-network/data/mockData.ts @@ -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 = { diff --git a/src/modules/agent-network/useProviderCatalog.ts b/src/modules/agent-network/useProviderCatalog.ts index 74fd3d0b3..80ce04384 100644 --- a/src/modules/agent-network/useProviderCatalog.ts +++ b/src/modules/agent-network/useProviderCatalog.ts @@ -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[]; }; diff --git a/src/modules/onboarding/OnboardingProvider.tsx b/src/modules/onboarding/OnboardingProvider.tsx index 19c1906eb..b17548a07 100644 --- a/src/modules/onboarding/OnboardingProvider.tsx +++ b/src/modules/onboarding/OnboardingProvider.tsx @@ -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;