mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f4a3564a | ||
|
|
6ca91287fc | ||
|
|
5a5e04ad35 | ||
|
|
598ecb196d | ||
|
|
7c8352822d | ||
|
|
e4f5046ff2 | ||
|
|
d5f61efc1a | ||
|
|
3503141c56 | ||
|
|
a27e4654cf | ||
|
|
b9569f48d5 | ||
|
|
9694bb753d |
No files matched your search
@@ -39,7 +39,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
repository: netbirdio/IronRDP
|
repository: netbirdio/IronRDP
|
||||||
latest: true
|
tag: v0.0.2
|
||||||
fileName: "*.ts"
|
fileName: "*.ts"
|
||||||
out-file-path: 'public/ironrdp-pkg'
|
out-file-path: 'public/ironrdp-pkg'
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
repository: netbirdio/IronRDP
|
repository: netbirdio/IronRDP
|
||||||
latest: true
|
tag: v0.0.2
|
||||||
fileName: "*.js"
|
fileName: "*.js"
|
||||||
out-file-path: 'public/ironrdp-pkg'
|
out-file-path: 'public/ironrdp-pkg'
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
repository: netbirdio/IronRDP
|
repository: netbirdio/IronRDP
|
||||||
latest: true
|
tag: v0.0.2
|
||||||
fileName: "ironrdp_web_bg.wasm"
|
fileName: "ironrdp_web_bg.wasm"
|
||||||
out-file-path: 'public/ironrdp-pkg'
|
out-file-path: 'public/ironrdp-pkg'
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"tag": "Preview",
|
"tag": "New",
|
||||||
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
|
"text": "The new NetBird desktop app is here - now available in the v0.75 release.",
|
||||||
"link": "https://github.com/netbirdio/netbird/discussions/6483",
|
"link": "https://netbird.io/knowledge-hub/netbird-v0-75-new-desktop-app",
|
||||||
"linkText": "Learn more",
|
"linkText": "Learn more",
|
||||||
"variant": "important",
|
"variant": "important",
|
||||||
"isExternal": true,
|
"isExternal": true,
|
||||||
|
|||||||
@@ -433,3 +433,53 @@ export async function deleteUserByEmail(page: Page, email: string) {
|
|||||||
await deleteUserById(page, user.id);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../helpers/fixtures";
|
import { test, expect } from "../helpers/fixtures";
|
||||||
import { navigateTo } from "../helpers/auth";
|
import { navigateTo } from "../helpers/auth";
|
||||||
import { generateRandomName } from "../helpers/utils";
|
import { clearScrollLock, generateRandomName } from "../helpers/utils";
|
||||||
import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api";
|
import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api";
|
||||||
|
|
||||||
let setupKeys: string[] = [];
|
let setupKeys: string[] = [];
|
||||||
@@ -118,19 +118,34 @@ async function createSetupKey(
|
|||||||
await expect(page.getByText(opts.name)).toBeVisible();
|
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,
|
page: import("@playwright/test").Page,
|
||||||
name: string,
|
name: string,
|
||||||
) {
|
) {
|
||||||
// Row actions are now behind a dropdown menu.
|
await clearScrollLock(page);
|
||||||
await page
|
await page
|
||||||
.locator("tr")
|
.locator("tr")
|
||||||
.filter({ hasText: name })
|
.filter({ hasText: name })
|
||||||
.getByTestId("setup-key-actions")
|
.getByTestId("setup-key-actions")
|
||||||
.click({ force: true });
|
.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(
|
const responsePromise = page.waitForResponse(
|
||||||
(resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT",
|
(resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT",
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
@@ -151,13 +166,23 @@ async function deleteSetupKey(
|
|||||||
name: string,
|
name: string,
|
||||||
) {
|
) {
|
||||||
// Row actions are now behind a dropdown menu.
|
// Row actions are now behind a dropdown menu.
|
||||||
await page
|
await openRowActions(page, name);
|
||||||
.locator("tr")
|
const deleteItem = page.locator(
|
||||||
.filter({ hasText: name })
|
'[data-testid="delete-setup-key"]:not([data-disabled])',
|
||||||
.getByTestId("setup-key-actions")
|
);
|
||||||
.click({ force: true });
|
await expect(deleteItem).toBeVisible();
|
||||||
await page.getByTestId("delete-setup-key").click({ force: true });
|
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 page.getByTestId("confirmation.confirm").click();
|
||||||
|
await responsePromise;
|
||||||
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
|
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
|
||||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Breadcrumbs from "@components/Breadcrumbs";
|
import Breadcrumbs from "@components/Breadcrumbs";
|
||||||
|
import InlineLink from "@components/InlineLink";
|
||||||
import Paragraph from "@components/Paragraph";
|
import Paragraph from "@components/Paragraph";
|
||||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||||
import { VerticalTabs } from "@components/VerticalTabs";
|
import { VerticalTabs } from "@components/VerticalTabs";
|
||||||
import * as Tabs from "@radix-ui/react-tabs";
|
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 { 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 AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||||
import PeersProvider from "@/contexts/PeersProvider";
|
import PeersProvider from "@/contexts/PeersProvider";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
|
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
|
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
|
||||||
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
|
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
|
||||||
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
|
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
|
||||||
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
|
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
|
||||||
|
|
||||||
|
const ClustersTable = lazy(
|
||||||
|
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
|
||||||
|
);
|
||||||
|
|
||||||
const TAB_BUDGET_SETTINGS = "budget-settings";
|
const TAB_BUDGET_SETTINGS = "budget-settings";
|
||||||
const TAB_LOG_SETTINGS = "log-settings";
|
const TAB_LOG_SETTINGS = "log-settings";
|
||||||
|
const TAB_CLUSTERS = "clusters";
|
||||||
|
|
||||||
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
|
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
|
||||||
// Usage & Logs — global limits and log-collection controls — using the same
|
// 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".
|
// Settings page so it reads as "settings for the Agent Network".
|
||||||
export default function AgentNetworkConfigurationPage() {
|
export default function AgentNetworkConfigurationPage() {
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
|
const { only: agentNetworkOnly } = useAgentNetworkMode();
|
||||||
const queryParams = useSearchParams();
|
const queryParams = useSearchParams();
|
||||||
const queryTab = queryParams.get("tab");
|
const queryTab = queryParams.get("tab");
|
||||||
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
|
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
|
||||||
@@ -47,6 +56,10 @@ export default function AgentNetworkConfigurationPage() {
|
|||||||
<ScrollText size={14} />
|
<ScrollText size={14} />
|
||||||
Log Collection
|
Log Collection
|
||||||
</VerticalTabs.Trigger>
|
</VerticalTabs.Trigger>
|
||||||
|
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
|
||||||
|
<ServerIcon size={14} />
|
||||||
|
Clusters
|
||||||
|
</VerticalTabs.Trigger>
|
||||||
</VerticalTabs.List>
|
</VerticalTabs.List>
|
||||||
<RestrictedAccess
|
<RestrictedAccess
|
||||||
page={"Configuration"}
|
page={"Configuration"}
|
||||||
@@ -82,6 +95,27 @@ export default function AgentNetworkConfigurationPage() {
|
|||||||
<AgentAccountControlsCard />
|
<AgentAccountControlsCard />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Tabs.Content>
|
</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>
|
</div>
|
||||||
</AIProvidersProvider>
|
</AIProvidersProvider>
|
||||||
</PeersProvider>
|
</PeersProvider>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import useFetchApi from "@utils/api";
|
|||||||
import { Loader2Icon } from "lucide-react";
|
import { Loader2Icon } from "lucide-react";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Peer } from "@/interfaces/Peer";
|
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 { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
|
||||||
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
|
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
|
||||||
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
|
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
|
||||||
@@ -138,6 +140,8 @@ function RDPSession({ peer }: Props) {
|
|||||||
domain: credentials.domain,
|
domain: credentials.domain,
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
|
dynamicResize:
|
||||||
|
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS,
|
||||||
});
|
});
|
||||||
if (result === RDPStatus.CONNECTED) {
|
if (result === RDPStatus.CONNECTED) {
|
||||||
connected.current = true;
|
connected.current = true;
|
||||||
@@ -182,7 +186,18 @@ function RDPSession({ peer }: Props) {
|
|||||||
if (client.error) {
|
if (client.error) {
|
||||||
sendErrorNotification("NetBird Client Error", 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
|
* Close credentials modal when RDP is connected
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ export default function PoliciesProvider({ children }: Props) {
|
|||||||
return created.id!;
|
return created.id!;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sources = await Promise.all(
|
const sources = rule.sourceResource
|
||||||
(rule.sources ?? []).map(resolveGroup),
|
? undefined
|
||||||
).then((ids) => ids.filter(Boolean) as string[]);
|
: await Promise.all(
|
||||||
|
(rule.sources ?? []).map(resolveGroup),
|
||||||
|
).then((ids) => ids.filter(Boolean) as string[]);
|
||||||
|
|
||||||
const destinations = rule.destinationResource
|
const destinations = rule.destinationResource
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const ENDPOINT_EXISTS_ERROR_CODES = [401, 403, 405];
|
|||||||
* activation be picked up without forcing the user to clear storage.
|
* activation be picked up without forcing the user to clear storage.
|
||||||
*/
|
*/
|
||||||
const LICENSE_CACHE_PREFIX = "netbird-licensed:";
|
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}`;
|
const licenseCacheKey = () => `${LICENSE_CACHE_PREFIX}${loadConfig().apiOrigin}`;
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,21 @@ export const CrowdSecMode = {
|
|||||||
|
|
||||||
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof 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 {
|
export interface AccessRestrictions {
|
||||||
allowed_cidrs?: string[];
|
allowed_cidrs?: string[];
|
||||||
blocked_cidrs?: string[];
|
blocked_cidrs?: string[];
|
||||||
allowed_countries?: string[];
|
allowed_countries?: string[];
|
||||||
blocked_countries?: string[];
|
blocked_countries?: string[];
|
||||||
crowdsec_mode?: CrowdSecMode;
|
crowdsec_mode?: CrowdSecMode;
|
||||||
|
appsec_mode?: AppSecMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReverseProxyMeta {
|
export interface ReverseProxyMeta {
|
||||||
@@ -122,6 +131,7 @@ export interface ReverseProxyDomain {
|
|||||||
supports_custom_ports?: boolean;
|
supports_custom_ports?: boolean;
|
||||||
require_subdomain?: boolean;
|
require_subdomain?: boolean;
|
||||||
supports_crowdsec?: boolean;
|
supports_crowdsec?: boolean;
|
||||||
|
supports_appsec?: boolean;
|
||||||
supports_private?: boolean;
|
supports_private?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +198,7 @@ export interface ReverseProxyCluster {
|
|||||||
supports_custom_ports?: boolean;
|
supports_custom_ports?: boolean;
|
||||||
require_subdomain?: boolean;
|
require_subdomain?: boolean;
|
||||||
supports_crowdsec?: boolean;
|
supports_crowdsec?: boolean;
|
||||||
|
supports_appsec?: boolean;
|
||||||
// True when at least one connected proxy in this cluster is running embedded
|
// 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.
|
// in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel.
|
||||||
// Lets the dashboard distinguish per-peer / private clusters from centralised
|
// Lets the dashboard distinguish per-peer / private clusters from centralised
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ function upstreamUrlPlaceholder(providerId: AIProviderId): string {
|
|||||||
return "https://api.portkey.ai";
|
return "https://api.portkey.ai";
|
||||||
case "vllm":
|
case "vllm":
|
||||||
return "https://your-vllm-host:8000";
|
return "https://your-vllm-host:8000";
|
||||||
|
case "kimi_api":
|
||||||
|
return "https://api.moonshot.ai";
|
||||||
case "custom":
|
case "custom":
|
||||||
return "https://your-llm-host";
|
return "https://your-llm-host";
|
||||||
default:
|
default:
|
||||||
@@ -217,6 +219,9 @@ export default function AIProviderModal({
|
|||||||
const [skipTlsVerification, setSkipTlsVerification] = useState<boolean>(
|
const [skipTlsVerification, setSkipTlsVerification] = useState<boolean>(
|
||||||
provider?.skipTlsVerification ?? false,
|
provider?.skipTlsVerification ?? false,
|
||||||
);
|
);
|
||||||
|
const [metadataDisabled, setMetadataDisabled] = useState<boolean>(
|
||||||
|
provider?.metadataDisabled ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
const catalog = getById(providerId);
|
const catalog = getById(providerId);
|
||||||
// Custom-kind providers (the generic "Custom" entry and named self-hosted
|
// Custom-kind providers (the generic "Custom" entry and named self-hosted
|
||||||
@@ -262,7 +267,8 @@ export default function AIProviderModal({
|
|||||||
providerId === "bifrost" ||
|
providerId === "bifrost" ||
|
||||||
providerId === "cloudflare_ai_gateway" ||
|
providerId === "cloudflare_ai_gateway" ||
|
||||||
providerId === "vercel_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
|
// 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
|
// the new type doesn't show mappings, snap back to the Provider tab
|
||||||
@@ -340,6 +346,7 @@ export default function AIProviderModal({
|
|||||||
setIdentityHeaderUserId(provider.identityHeaderUserId ?? "");
|
setIdentityHeaderUserId(provider.identityHeaderUserId ?? "");
|
||||||
setIdentityHeaderGroups(provider.identityHeaderGroups ?? "");
|
setIdentityHeaderGroups(provider.identityHeaderGroups ?? "");
|
||||||
setSkipTlsVerification(provider.skipTlsVerification ?? false);
|
setSkipTlsVerification(provider.skipTlsVerification ?? false);
|
||||||
|
setMetadataDisabled(provider.metadataDisabled ?? false);
|
||||||
} else {
|
} else {
|
||||||
const fallback = getById("openai_api");
|
const fallback = getById("openai_api");
|
||||||
setProviderId("openai_api");
|
setProviderId("openai_api");
|
||||||
@@ -354,6 +361,7 @@ export default function AIProviderModal({
|
|||||||
setIdentityHeaderUserId("");
|
setIdentityHeaderUserId("");
|
||||||
setIdentityHeaderGroups("");
|
setIdentityHeaderGroups("");
|
||||||
setSkipTlsVerification(false);
|
setSkipTlsVerification(false);
|
||||||
|
setMetadataDisabled(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -405,6 +413,7 @@ export default function AIProviderModal({
|
|||||||
extraValues: sanitizedExtraValues,
|
extraValues: sanitizedExtraValues,
|
||||||
...identityOverrides,
|
...identityOverrides,
|
||||||
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
|
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
|
||||||
|
metadataDisabled,
|
||||||
// Only forward the API key when the user actually rotated it
|
// Only forward the API key when the user actually rotated it
|
||||||
...(apiKey && apiKey !== "••••••••" ? { apiKey } : {}),
|
...(apiKey && apiKey !== "••••••••" ? { apiKey } : {}),
|
||||||
});
|
});
|
||||||
@@ -420,6 +429,7 @@ export default function AIProviderModal({
|
|||||||
extraValues: sanitizedExtraValues,
|
extraValues: sanitizedExtraValues,
|
||||||
...identityOverrides,
|
...identityOverrides,
|
||||||
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
|
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
|
||||||
|
metadataDisabled,
|
||||||
models,
|
models,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
@@ -609,7 +619,20 @@ export default function AIProviderModal({
|
|||||||
</FormRow>
|
</FormRow>
|
||||||
|
|
||||||
<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)}
|
helpText={upstreamUrlHelpText(providerId)}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -730,7 +753,7 @@ export default function AIProviderModal({
|
|||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
customPrefix={<KeyRound size={14} />}
|
customPrefix={<KeyRound size={14} />}
|
||||||
placeholder={
|
placeholder={
|
||||||
providerId === "openai_api"
|
providerId === "openai_api" || providerId === "kimi_api"
|
||||||
? "sk-..."
|
? "sk-..."
|
||||||
: providerId === "anthropic_api"
|
: providerId === "anthropic_api"
|
||||||
? "sk-ant-..."
|
? "sk-ant-..."
|
||||||
@@ -785,6 +808,23 @@ export default function AIProviderModal({
|
|||||||
{showMappings && providerId === "litellm_proxy" && (
|
{showMappings && providerId === "litellm_proxy" && (
|
||||||
<TabsContent value={"mappings"} className={"pb-8"}>
|
<TabsContent value={"mappings"} className={"pb-8"}>
|
||||||
<div className={"px-8 pt-3 flex-col flex gap-4"}>
|
<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>
|
<div>
|
||||||
<Label>Identity Mappings</Label>
|
<Label>Identity Mappings</Label>
|
||||||
<HelpText className={"mb-0"}>
|
<HelpText className={"mb-0"}>
|
||||||
@@ -987,6 +1027,61 @@ export default function AIProviderModal({
|
|||||||
</TabsContent>
|
</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'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" && (
|
{showMappings && providerId === "vercel_ai_gateway" && (
|
||||||
<TabsContent value={"mappings"} className={"pb-8"}>
|
<TabsContent value={"mappings"} className={"pb-8"}>
|
||||||
<div className={"px-8 pt-3 flex-col flex gap-4"}>
|
<div className={"px-8 pt-3 flex-col flex gap-4"}>
|
||||||
@@ -1164,8 +1259,11 @@ export default function AIProviderModal({
|
|||||||
<div className={"w-full"}>
|
<div className={"w-full"}>
|
||||||
<Paragraph className={"text-sm mt-auto"}>
|
<Paragraph className={"text-sm mt-auto"}>
|
||||||
Learn more about
|
Learn more about
|
||||||
<InlineLink href={"https://docs.netbird.io/"} target={"_blank"}>
|
<InlineLink
|
||||||
Agent Network
|
href={"https://docs.netbird.io/agent-network/providers"}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Agent Network Providers
|
||||||
<ExternalLinkIcon size={12} />
|
<ExternalLinkIcon size={12} />
|
||||||
</InlineLink>
|
</InlineLink>
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export type APIProvider = {
|
|||||||
// Skip TLS certificate verification on upstream requests (custom providers
|
// Skip TLS certificate verification on upstream requests (custom providers
|
||||||
// with self-signed certs). Off by default.
|
// with self-signed certs). Off by default.
|
||||||
skip_tls_verification?: boolean;
|
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;
|
enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -67,6 +70,7 @@ export type APIProviderRequest = {
|
|||||||
identity_header_user_id?: string;
|
identity_header_user_id?: string;
|
||||||
identity_header_groups?: string;
|
identity_header_groups?: string;
|
||||||
skip_tls_verification?: boolean;
|
skip_tls_verification?: boolean;
|
||||||
|
metadata_disabled?: boolean;
|
||||||
models: APIProviderModel[];
|
models: APIProviderModel[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
@@ -84,6 +88,8 @@ export type ProviderConnectInput = {
|
|||||||
identityHeaderGroups?: string;
|
identityHeaderGroups?: string;
|
||||||
// Skip upstream TLS verification (custom providers with self-signed certs).
|
// Skip upstream TLS verification (custom providers with self-signed certs).
|
||||||
skipTlsVerification?: boolean;
|
skipTlsVerification?: boolean;
|
||||||
|
// Disable identity metadata injection (user + authorizing group). Off by default.
|
||||||
|
metadataDisabled?: boolean;
|
||||||
models: ProviderModel[];
|
models: ProviderModel[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
@@ -97,6 +103,7 @@ export type ProviderUpdateInput = {
|
|||||||
identityHeaderUserId?: string;
|
identityHeaderUserId?: string;
|
||||||
identityHeaderGroups?: string;
|
identityHeaderGroups?: string;
|
||||||
skipTlsVerification?: boolean;
|
skipTlsVerification?: boolean;
|
||||||
|
metadataDisabled?: boolean;
|
||||||
models?: ProviderModel[];
|
models?: ProviderModel[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
@@ -159,6 +166,7 @@ function fromAPI(p: APIProvider): AIProvider {
|
|||||||
identityHeaderUserId: p.identity_header_user_id,
|
identityHeaderUserId: p.identity_header_user_id,
|
||||||
identityHeaderGroups: p.identity_header_groups,
|
identityHeaderGroups: p.identity_header_groups,
|
||||||
skipTlsVerification: p.skip_tls_verification ?? false,
|
skipTlsVerification: p.skip_tls_verification ?? false,
|
||||||
|
metadataDisabled: p.metadata_disabled ?? false,
|
||||||
status: p.enabled ? "active" : "disabled",
|
status: p.enabled ? "active" : "disabled",
|
||||||
models,
|
models,
|
||||||
allowedGroups: [],
|
allowedGroups: [],
|
||||||
@@ -201,6 +209,7 @@ function toCreateRequest(input: ProviderConnectInput): APIProviderRequest {
|
|||||||
identity_header_user_id: input.identityHeaderUserId,
|
identity_header_user_id: input.identityHeaderUserId,
|
||||||
identity_header_groups: input.identityHeaderGroups,
|
identity_header_groups: input.identityHeaderGroups,
|
||||||
skip_tls_verification: input.skipTlsVerification,
|
skip_tls_verification: input.skipTlsVerification,
|
||||||
|
metadata_disabled: input.metadataDisabled,
|
||||||
models: toAPIModels(input.models),
|
models: toAPIModels(input.models),
|
||||||
enabled: input.enabled,
|
enabled: input.enabled,
|
||||||
};
|
};
|
||||||
@@ -647,6 +656,8 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
|||||||
updates.identityHeaderGroups ?? existing.identity_header_groups,
|
updates.identityHeaderGroups ?? existing.identity_header_groups,
|
||||||
skip_tls_verification:
|
skip_tls_verification:
|
||||||
updates.skipTlsVerification ?? existing.skip_tls_verification,
|
updates.skipTlsVerification ?? existing.skip_tls_verification,
|
||||||
|
metadata_disabled:
|
||||||
|
updates.metadataDisabled ?? existing.metadata_disabled,
|
||||||
models: updates.models
|
models: updates.models
|
||||||
? toAPIModels(updates.models)
|
? toAPIModels(updates.models)
|
||||||
: existing.models ?? [],
|
: existing.models ?? [],
|
||||||
|
|||||||
@@ -26,17 +26,39 @@ export default function AgentAccessLogExpandedRow({ entry }: Readonly<Props>) {
|
|||||||
const hasBody = hasPrompt || hasCompletion;
|
const hasBody = hasPrompt || hasCompletion;
|
||||||
const denyReason = formatDenyReason(entry.denyReason);
|
const denyReason = formatDenyReason(entry.denyReason);
|
||||||
|
|
||||||
|
const cacheRead = entry.cachedInputTokens ?? 0;
|
||||||
|
const cacheWrite = entry.cacheCreationTokens ?? 0;
|
||||||
const metadata: Record<string, string> = {
|
const metadata: Record<string, string> = {
|
||||||
"plg.llm.provider": entry.providerId,
|
"plg.llm.provider": entry.providerId,
|
||||||
"plg.llm.model": entry.model,
|
"plg.llm.model": entry.model,
|
||||||
"plg.llm.input_tokens": String(entry.inputTokens),
|
"plg.llm.input_tokens": String(entry.inputTokens),
|
||||||
"plg.llm.output_tokens": String(entry.outputTokens),
|
"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.cost_usd": entry.costUsd.toFixed(6),
|
||||||
"plg.llm.stream": entry.stream ? "true" : "false",
|
"plg.llm.stream": entry.stream ? "true" : "false",
|
||||||
"plg.agentnetwork.policy_name": entry.policyName,
|
"plg.agentnetwork.policy_name": entry.policyName,
|
||||||
"plg.agentnetwork.user_groups": (entry.userGroups ?? []).join(", "),
|
"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) {
|
if (isDeny) {
|
||||||
metadata["plg.llm_policy.decision"] = "deny";
|
metadata["plg.llm_policy.decision"] = "deny";
|
||||||
metadata["plg.llm_policy.reason"] = entry.denyReason ?? "unknown";
|
metadata["plg.llm_policy.reason"] = entry.denyReason ?? "unknown";
|
||||||
|
|||||||
@@ -261,13 +261,14 @@ export default function AgentAccessLogTable({
|
|||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span
|
<CostCell
|
||||||
className={
|
costUsd={row.original.costUsd}
|
||||||
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap"
|
cacheCostUsd={row.original.cacheCostUsd}
|
||||||
}
|
inputCostUsd={row.original.inputCostUsd}
|
||||||
>
|
cachedInputCostUsd={row.original.cachedInputCostUsd}
|
||||||
${row.original.costUsd.toFixed(4)}
|
cacheCreationCostUsd={row.original.cacheCreationCostUsd}
|
||||||
</span>
|
outputCostUsd={row.original.outputCostUsd}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -409,6 +410,8 @@ export default function AgentAccessLogTable({
|
|||||||
{
|
{
|
||||||
inputTokens: row.original.inputTokens,
|
inputTokens: row.original.inputTokens,
|
||||||
outputTokens: row.original.outputTokens,
|
outputTokens: row.original.outputTokens,
|
||||||
|
cachedInputTokens: row.original.cachedInputTokens,
|
||||||
|
cacheCreationTokens: row.original.cacheCreationTokens,
|
||||||
} as AIAccessLogEntry
|
} as AIAccessLogEntry
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -423,13 +426,14 @@ export default function AgentAccessLogTable({
|
|||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span
|
<CostCell
|
||||||
className={
|
costUsd={row.original.costUsd}
|
||||||
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap"
|
cacheCostUsd={row.original.cacheCostUsd}
|
||||||
}
|
inputCostUsd={row.original.inputCostUsd}
|
||||||
>
|
cachedInputCostUsd={row.original.cachedInputCostUsd}
|
||||||
${row.original.costUsd.toFixed(4)}
|
cacheCreationCostUsd={row.original.cacheCreationCostUsd}
|
||||||
</span>
|
outputCostUsd={row.original.outputCostUsd}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -999,31 +1003,43 @@ function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
|
|||||||
) {
|
) {
|
||||||
return <EmptyRow />;
|
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 (
|
return (
|
||||||
<FullTooltip
|
<FullTooltip
|
||||||
content={
|
content={
|
||||||
<div className={"text-xs flex flex-col gap-1"}>
|
<div className={"text-xs flex flex-col gap-1"}>
|
||||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||||
<ArrowUpIcon size={12} className={"text-sky-400 shrink-0"} />
|
|
||||||
<span className={"font-medium"}>
|
<span className={"font-medium"}>
|
||||||
{entry.inputTokens.toLocaleString()}
|
{entry.inputTokens.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className={"text-nb-gray-400"}>input</span>
|
<span className={"text-nb-gray-400"}>input</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||||
<ArrowDownIcon size={12} className={"text-netbird shrink-0"} />
|
|
||||||
<span className={"font-medium"}>
|
<span className={"font-medium"}>
|
||||||
{entry.outputTokens.toLocaleString()}
|
{entry.outputTokens.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className={"text-nb-gray-400"}>output</span>
|
<span className={"text-nb-gray-400"}>output</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
className={
|
className={
|
||||||
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
|
"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"}>
|
<span className={"font-medium text-nb-gray-200"}>
|
||||||
{total.toLocaleString()}
|
{total.toLocaleString()}
|
||||||
</span>
|
</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
|
// resolveProviderFn resolves a request's configured provider (by the router's
|
||||||
// stamped resolved_provider_id) — shared by the session cells so they reuse the
|
// stamped resolved_provider_id) — shared by the session cells so they reuse the
|
||||||
// flat row's provider resolution.
|
// flat row's provider resolution.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Code from "@components/Code";
|
import Code from "@components/Code";
|
||||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||||
|
import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider";
|
||||||
import Paragraph from "@components/Paragraph";
|
import Paragraph from "@components/Paragraph";
|
||||||
import SmallParagraph from "@components/SmallParagraph";
|
import SmallParagraph from "@components/SmallParagraph";
|
||||||
import SquareIcon from "@components/SquareIcon";
|
import SquareIcon from "@components/SquareIcon";
|
||||||
@@ -56,6 +57,7 @@ export function AgentConnectTabs({
|
|||||||
listClassName = "px-8",
|
listClassName = "px-8",
|
||||||
contentClassName = "px-6 py-2",
|
contentClassName = "px-6 py-2",
|
||||||
defaultTab = "claude-code",
|
defaultTab = "claude-code",
|
||||||
|
providerIds = [],
|
||||||
}: {
|
}: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
listClassName?: string;
|
listClassName?: string;
|
||||||
@@ -64,23 +66,39 @@ export function AgentConnectTabs({
|
|||||||
// matching tool (e.g. Anthropic → claude-code, OpenAI → curl). Keyed below
|
// matching tool (e.g. Anthropic → claude-code, OpenAI → curl). Keyed below
|
||||||
// so a late-resolving defaultTab still re-initialises the tabs.
|
// so a late-resolving defaultTab still re-initialises the tabs.
|
||||||
defaultTab?: string;
|
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 baseUrl = `https://${endpoint}`;
|
||||||
const openaiBase = `${baseUrl}/v1`;
|
const openaiBase = `${baseUrl}/v1`;
|
||||||
|
const hasKimi = providerIds.includes("kimi_api");
|
||||||
const [claudeMode, setClaudeMode] = React.useState<"config" | "shell">(
|
const [claudeMode, setClaudeMode] = React.useState<"config" | "shell">(
|
||||||
"config",
|
"config",
|
||||||
);
|
);
|
||||||
// Which backend the Claude Code config targets — Anthropic API direct, or
|
// Which backend the Claude Code config targets — Anthropic API direct,
|
||||||
// via Vertex AI / Bedrock. Switched in-tab instead of separate tabs.
|
// 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<
|
const [claudeProvider, setClaudeProvider] = React.useState<
|
||||||
"anthropic" | "vertex" | "bedrock"
|
"anthropic" | "vertex" | "bedrock" | "kimi"
|
||||||
>("anthropic");
|
>(kimiOnlyAnthropicShape ? "kimi" : "anthropic");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs key={defaultTab} defaultValue={defaultTab} className={"mt-2"}>
|
<Tabs key={defaultTab} defaultValue={defaultTab} className={"mt-2"}>
|
||||||
<TabsList justify={"start"} className={listClassName}>
|
<TabsList justify={"start"} className={listClassName}>
|
||||||
<TabsTrigger value={"claude-code"}>Claude Code</TabsTrigger>
|
<TabsTrigger value={"claude-code"}>Claude Code</TabsTrigger>
|
||||||
<TabsTrigger value={"codex"}>Codex</TabsTrigger>
|
<TabsTrigger value={"codex"}>Codex</TabsTrigger>
|
||||||
|
{hasKimi && <TabsTrigger value={"kimi-cli"}>Kimi CLI</TabsTrigger>}
|
||||||
<TabsTrigger value={"openai-sdk"}>OpenAI SDK</TabsTrigger>
|
<TabsTrigger value={"openai-sdk"}>OpenAI SDK</TabsTrigger>
|
||||||
<TabsTrigger value={"curl"}>cURL</TabsTrigger>
|
<TabsTrigger value={"curl"}>cURL</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -91,15 +109,20 @@ export function AgentConnectTabs({
|
|||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
value={claudeProvider}
|
value={claudeProvider}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
setClaudeProvider(v as "anthropic" | "vertex" | "bedrock")
|
setClaudeProvider(
|
||||||
|
v as "anthropic" | "vertex" | "bedrock" | "kimi",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
options={[
|
options={[
|
||||||
{ label: "Anthropic API", value: "anthropic" },
|
{ label: "Anthropic API", value: "anthropic" },
|
||||||
{ label: "Vertex AI", value: "vertex" },
|
{ label: "Vertex AI", value: "vertex" },
|
||||||
{ label: "Bedrock", value: "bedrock" },
|
{ label: "Bedrock", value: "bedrock" },
|
||||||
|
...(hasKimi
|
||||||
|
? [{ label: "Kimi (Moonshot AI)", value: "kimi" }]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
showValues={false}
|
showValues={false}
|
||||||
className={"w-[160px]"}
|
className={"!w-auto min-w-[160px]"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -195,6 +288,38 @@ export function AgentConnectTabs({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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 = "openai_legacy"</code>{" "}
|
||||||
|
with <code className={"font-mono"}>base_url = "{openaiBase}"</code>.
|
||||||
|
</SmallParagraph>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value={"openai-sdk"}>
|
<TabsContent value={"openai-sdk"}>
|
||||||
<div className={contentClassName}>
|
<div className={contentClassName}>
|
||||||
<Snippet
|
<Snippet
|
||||||
@@ -243,6 +368,9 @@ export default function AgentConnectModal({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
endpoint,
|
endpoint,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
|
// Rendered inside <AIProvidersProvider> (providers page); the connected
|
||||||
|
// provider ids gate which per-tool config variants the tabs offer.
|
||||||
|
const { providers } = useAIProviders();
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onOpenChange={onOpenChange}>
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
<ModalContent maxWidthClass={"max-w-2xl"}>
|
<ModalContent maxWidthClass={"max-w-2xl"}>
|
||||||
@@ -258,7 +386,10 @@ export default function AgentConnectModal({
|
|||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AgentConnectTabs endpoint={endpoint} />
|
<AgentConnectTabs
|
||||||
|
endpoint={endpoint}
|
||||||
|
providerIds={providers.map((p) => p.providerId)}
|
||||||
|
/>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ButtonGroup from "@components/ButtonGroup";
|
import ButtonGroup from "@components/ButtonGroup";
|
||||||
|
import FullTooltip from "@components/FullTooltip";
|
||||||
import InlineLink from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import SquareIcon from "@components/SquareIcon";
|
import SquareIcon from "@components/SquareIcon";
|
||||||
import { DataTable } from "@components/table/DataTable";
|
import { DataTable } from "@components/table/DataTable";
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "chart.js";
|
} from "chart.js";
|
||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
|
import { cn } from "@utils/helpers";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { ActivityIcon, ExternalLinkIcon } from "lucide-react";
|
import { ActivityIcon, ExternalLinkIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -42,6 +44,18 @@ type DayBucket = {
|
|||||||
input: number;
|
input: number;
|
||||||
output: number;
|
output: number;
|
||||||
cost: 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
|
// AgentOverviewPanel shows account consumption over time as a per-day bar
|
||||||
@@ -206,13 +220,43 @@ function DailyBreakdownTable({ daily }: { daily: DayBucket[] }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "total",
|
id: "total",
|
||||||
accessorFn: (row) => row.input + row.output,
|
accessorFn: (row) =>
|
||||||
|
row.input + row.output + row.cacheRead + row.cacheWrite,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableHeader column={column}>Total Tokens</DataTableHeader>
|
<DataTableHeader column={column}>Total Tokens</DataTableHeader>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
// Total includes the additive prompt-cache buckets; hover breaks them out.
|
||||||
<NumberCell value={row.original.input + row.original.output} />
|
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",
|
id: "cost",
|
||||||
@@ -220,13 +264,74 @@ function DailyBreakdownTable({ daily }: { daily: DayBucket[] }) {
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableHeader column={column}>Cost</DataTableHeader>
|
<DataTableHeader column={column}>Cost</DataTableHeader>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
// Mirrors the access log's Cost hover: one row per bucket the provider
|
||||||
<span
|
// bills separately when the server sends the split, otherwise the coarse
|
||||||
className={"text-nb-gray-300 px-3 py-2 font-mono whitespace-nowrap"}
|
// 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.
|
||||||
${row.original.cost.toFixed(2)}
|
cell: ({ row }) => {
|
||||||
</span>
|
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({
|
function ConsumptionByDayChart({
|
||||||
daily,
|
daily,
|
||||||
metric,
|
metric,
|
||||||
@@ -340,6 +498,25 @@ function ConsumptionByDayChart({
|
|||||||
: `${ctx.dataset.label}: ${Number(
|
: `${ctx.dataset.label}: ${Number(
|
||||||
ctx.parsed.y,
|
ctx.parsed.y,
|
||||||
).toLocaleString()}`,
|
).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,
|
input: b.input_tokens ?? 0,
|
||||||
output: b.output_tokens ?? 0,
|
output: b.output_tokens ?? 0,
|
||||||
cost: b.cost_usd ?? 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.
|
// Cap at a year of days as a defensive bound against a bad range.
|
||||||
for (let i = 0; i < 366 && !cur.isAfter(end); i++) {
|
for (let i = 0; i < 366 && !cur.isAfter(end); i++) {
|
||||||
const key = cur.format("YYYY-MM-DD");
|
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");
|
cur = cur.add(1, "day");
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export type APIAgentNetworkAccessLog = {
|
|||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
cost_usd: 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;
|
user_id?: string;
|
||||||
source_ip?: string;
|
source_ip?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
@@ -63,6 +74,16 @@ export type APIAgentNetworkAccessLogSession = {
|
|||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
cost_usd: 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[];
|
providers?: string[];
|
||||||
models?: string[];
|
models?: string[];
|
||||||
decision: string;
|
decision: string;
|
||||||
@@ -77,6 +98,16 @@ export type APIAgentNetworkUsageBucket = {
|
|||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
cost_usd: 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";
|
export type UsageGranularity = "day" | "week" | "month";
|
||||||
@@ -132,6 +163,7 @@ const KNOWN_PROVIDER_IDS: AIProviderId[] = [
|
|||||||
"bedrock_api",
|
"bedrock_api",
|
||||||
"vertex_ai_api",
|
"vertex_ai_api",
|
||||||
"mistral_api",
|
"mistral_api",
|
||||||
|
"kimi_api",
|
||||||
"custom",
|
"custom",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -184,7 +216,14 @@ export function accessLogFromAgentAPI(
|
|||||||
model: entry.model ?? "",
|
model: entry.model ?? "",
|
||||||
inputTokens: entry.input_tokens ?? 0,
|
inputTokens: entry.input_tokens ?? 0,
|
||||||
outputTokens: entry.output_tokens ?? 0,
|
outputTokens: entry.output_tokens ?? 0,
|
||||||
|
cachedInputTokens: entry.cached_input_tokens ?? 0,
|
||||||
|
cacheCreationTokens: entry.cache_creation_tokens ?? 0,
|
||||||
costUsd: entry.cost_usd ?? 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,
|
durationMs: entry.duration_ms,
|
||||||
decision,
|
decision,
|
||||||
denyReason: decision === "deny" ? entry.deny_reason : undefined,
|
denyReason: decision === "deny" ? entry.deny_reason : undefined,
|
||||||
@@ -227,7 +266,14 @@ export function accessLogSessionFromAgentAPI(
|
|||||||
inputTokens: session.input_tokens ?? 0,
|
inputTokens: session.input_tokens ?? 0,
|
||||||
outputTokens: session.output_tokens ?? 0,
|
outputTokens: session.output_tokens ?? 0,
|
||||||
totalTokens: session.total_tokens ?? 0,
|
totalTokens: session.total_tokens ?? 0,
|
||||||
|
cachedInputTokens: session.cached_input_tokens ?? 0,
|
||||||
|
cacheCreationTokens: session.cache_creation_tokens ?? 0,
|
||||||
costUsd: session.cost_usd ?? 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 ?? [],
|
providers: session.providers ?? [],
|
||||||
models: session.models ?? [],
|
models: session.models ?? [],
|
||||||
decision,
|
decision,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type AIProviderId =
|
|||||||
| "bedrock_api"
|
| "bedrock_api"
|
||||||
| "vertex_ai_api"
|
| "vertex_ai_api"
|
||||||
| "mistral_api"
|
| "mistral_api"
|
||||||
|
| "kimi_api"
|
||||||
| "litellm_proxy"
|
| "litellm_proxy"
|
||||||
| "portkey"
|
| "portkey"
|
||||||
| "bifrost"
|
| "bifrost"
|
||||||
@@ -64,6 +65,9 @@ export type AIProvider = {
|
|||||||
// Skip TLS certificate verification on upstream requests — for custom
|
// Skip TLS certificate verification on upstream requests — for custom
|
||||||
// providers using self-signed certs. Off by default.
|
// providers using self-signed certs. Off by default.
|
||||||
skipTlsVerification?: boolean;
|
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;
|
status: AIProviderStatus;
|
||||||
models: ProviderModel[];
|
models: ProviderModel[];
|
||||||
allowedGroups: string[];
|
allowedGroups: string[];
|
||||||
@@ -171,7 +175,17 @@ export type AIAccessLogEntry = {
|
|||||||
model: string;
|
model: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: 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;
|
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;
|
durationMs: number;
|
||||||
decision: AIAccessLogDecision;
|
decision: AIAccessLogDecision;
|
||||||
denyReason?: string;
|
denyReason?: string;
|
||||||
@@ -209,7 +223,16 @@ export type AIAccessLogSession = {
|
|||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
|
cachedInputTokens?: number;
|
||||||
|
cacheCreationTokens?: number;
|
||||||
costUsd: 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
|
providers: string[]; // distinct vendor ids seen in the session
|
||||||
models: string[]; // distinct models seen in the session
|
models: string[]; // distinct models seen in the session
|
||||||
decision: AIAccessLogDecision;
|
decision: AIAccessLogDecision;
|
||||||
|
|||||||
@@ -169,8 +169,11 @@ export default function AgentProvidersTable({
|
|||||||
learnMore={
|
learnMore={
|
||||||
<>
|
<>
|
||||||
Learn more about
|
Learn more about
|
||||||
<InlineLink href={"https://docs.netbird.io/"} target={"_blank"}>
|
<InlineLink
|
||||||
Agent Network
|
href={"https://docs.netbird.io/agent-network/providers"}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Agent Network Providers
|
||||||
<ExternalLinkIcon size={12} />
|
<ExternalLinkIcon size={12} />
|
||||||
</InlineLink>
|
</InlineLink>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Loaded 20 of 34 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user