Compare commits

...
Author SHA1 Message Date
Viktor Liu a67a23fc1f Add allow_match toggle and browser-detected country to reverse proxy access rules 2026-07-25 08:25:30 +02:00
Misha Bragin 5a5e04ad35 Add 0.75 announcement (#727) 2026-07-23 21:05:52 +02:00
Maycon Santos 598ecb196d remove rc releases logic for agent networks (#725) 2026-07-23 20:29:09 +02:00
Maycon Santosandbraginini 7c8352822d Add Kimi (Moonshot AI) provider support to Agent Network UI (#724)
* Add Kimi (Moonshot AI) provider support to Agent Network UI

Pairs with the management catalog's new kimi_api entry:

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

* Add Kimi provider e2e spec

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

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

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

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

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

* Kimi CLI uses the bare endpoint base URL

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

* Kimi tooltip

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

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

* Restored the note on the Kimi CLI tab

---------

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

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

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

No files matched your search

+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
@@ -48,7 +48,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
@@ -57,7 +57,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
+3 -3
View File
@@ -1,8 +1,8 @@
[
{
"tag": "Preview",
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
"link": "https://github.com/netbirdio/netbird/discussions/6483",
"tag": "New",
"text": "The new NetBird desktop app is here - now available in the v0.75 release.",
"link": "https://netbird.io/knowledge-hub/netbird-v0-75-new-desktop-app",
"linkText": "Learn more",
"variant": "important",
"isExternal": true,
+50
View File
@@ -433,3 +433,53 @@ export async function deleteUserByEmail(page: Page, email: string) {
await deleteUserById(page, user.id);
}
}
// ---------------------------------------------------------------------------
// Agent Network
// ---------------------------------------------------------------------------
type AgentNetworkCatalogProvider = {
id: string;
name: string;
};
type AgentNetworkProvider = {
id: string;
name: string;
provider_id: string;
};
/** List the Agent Network provider catalog (server-defined provider types). */
export async function listAgentNetworkCatalog(
page: Page,
): Promise<AgentNetworkCatalogProvider[]> {
return apiGet<AgentNetworkCatalogProvider[]>(
page,
"/agent-network/catalog/providers",
);
}
/** List connected Agent Network providers. */
export async function listAgentNetworkProviders(
page: Page,
): Promise<AgentNetworkProvider[]> {
return apiGet<AgentNetworkProvider[]>(page, "/agent-network/providers");
}
/** Delete an Agent Network provider by ID. */
export async function deleteAgentNetworkProviderById(page: Page, id: string) {
await apiDelete(page, `/agent-network/providers/${id}`);
}
/** Delete all Agent Network providers whose name starts with the prefix. */
export async function deleteAgentNetworkProvidersByPrefix(
page: Page,
prefix: string,
) {
const providers = await listAgentNetworkProviders(page);
for (const p of providers) {
if (p.name.startsWith(prefix)) {
await deleteAgentNetworkProviderById(page, p.id);
}
}
}
@@ -0,0 +1,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,25 +1,33 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import * as Tabs from "@radix-ui/react-tabs";
import { Gauge, ScrollText } from "lucide-react";
import { ExternalLinkIcon, Gauge, ScrollText, ServerIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { Suspense, useEffect, useState } from "react";
import React, { lazy, Suspense, useEffect, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
const ClustersTable = lazy(
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
);
const TAB_BUDGET_SETTINGS = "budget-settings";
const TAB_LOG_SETTINGS = "log-settings";
const TAB_CLUSTERS = "clusters";
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
// Usage & Logs — global limits and log-collection controls — using the same
@@ -27,6 +35,7 @@ const TAB_LOG_SETTINGS = "log-settings";
// Settings page so it reads as "settings for the Agent Network".
export default function AgentNetworkConfigurationPage() {
const { permission } = usePermissions();
const { only: agentNetworkOnly } = useAgentNetworkMode();
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
@@ -47,6 +56,10 @@ export default function AgentNetworkConfigurationPage() {
<ScrollText size={14} />
Log Collection
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
<ServerIcon size={14} />
Clusters
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess
page={"Configuration"}
@@ -82,6 +95,27 @@ export default function AgentNetworkConfigurationPage() {
<AgentAccountControlsCard />
</Suspense>
</Tabs.Content>
<Tabs.Content value={TAB_CLUSTERS} className={"w-full"}>
<ConfigTabHeader
label={"Clusters"}
href={"/agent-network/configuration?tab=clusters"}
>
{agentNetworkOnly
? "Proxy clusters route your agents' traffic to AI providers and run on your own infrastructure. Add multiple clusters to scale your environment."
: "Proxy clusters route inbound traffic to your services. Shared clusters are run by the platform; account clusters (self-hosted) run on your own infrastructure."}{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</ConfigTabHeader>
<Suspense fallback={<SkeletonTable />}>
<ClustersTable />
</Suspense>
</Tabs.Content>
</div>
</AIProvidersProvider>
</PeersProvider>
+16 -1
View File
@@ -7,6 +7,8 @@ import useFetchApi from "@utils/api";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Peer } from "@/interfaces/Peer";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
@@ -138,6 +140,8 @@ function RDPSession({ peer }: Props) {
domain: credentials.domain,
width: window.innerWidth,
height: window.innerHeight,
dynamicResize:
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS,
});
if (result === RDPStatus.CONNECTED) {
connected.current = true;
@@ -182,7 +186,18 @@ function RDPSession({ peer }: Props) {
if (client.error) {
sendErrorNotification("NetBird Client Error", client.error);
}
}, [rdp, client]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rdp.error, client.error]);
/**
* Return to the credentials screen when an established session drops with an
* error, so the user can see what happened and retry.
*/
useEffect(() => {
if (connected.current && rdp.error) {
reset();
}
}, [rdp.error, reset]);
/**
* Close credentials modal when RDP is connected
+42 -2
View File
@@ -6,6 +6,26 @@ import { createElement, useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { useCountries } from "@/contexts/CountryProvider";
// browserCountryCode returns the ISO 3166-1 alpha-2 region from the browser's
// preferred languages (e.g. "en-US" -> "US"), or undefined when none carries a
// region. It reads navigator.languages, so it is client-only.
function browserCountryCode(): string | undefined {
if (typeof navigator === "undefined") return undefined;
const langs = navigator.languages?.length
? navigator.languages
: [navigator.language];
for (const lang of langs) {
if (!lang) continue;
try {
const region = new Intl.Locale(lang).region;
if (region) return region.toUpperCase();
} catch {
// Ignore malformed language tags and try the next one.
}
}
return undefined;
}
type Props = {
value: string;
onChange: (value: string) => void;
@@ -13,7 +33,13 @@ type Props = {
popoverWidth?: "auto" | "content" | number;
truncate?: boolean;
};
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
export const CountrySelector = ({
value,
onChange,
iconSize = 20,
popoverWidth,
truncate,
}: Props) => {
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
@@ -36,6 +62,20 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
}) as SelectOption[];
}, [countries]);
// Surface the browser-detected country at the top so the common case is one
// click away. Falls back to the original order when detection fails or the
// code is not in the list.
const orderedList = useMemo(() => {
if (!countryList?.length) return countryList;
const code = browserCountryCode();
if (!code) return countryList;
const index = countryList.findIndex((option) => option.value === code);
if (index <= 0) return countryList;
const reordered = countryList.slice();
const [detected] = reordered.splice(index, 1);
return [detected, ...reordered];
}, [countryList]);
return (
<div className={"block w-full"}>
<SelectDropdown
@@ -46,7 +86,7 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
value={value}
onChange={onChange}
iconSize={iconSize}
options={countryList || []}
options={orderedList || []}
popoverWidth={popoverWidth}
truncate={truncate}
/>
+5 -3
View File
@@ -64,9 +64,11 @@ export default function PoliciesProvider({ children }: Props) {
return created.id!;
};
const sources = await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const sources = rule.sourceResource
? undefined
: await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const destinations = rule.destinationResource
? undefined
+1 -1
View File
@@ -21,7 +21,7 @@ const ENDPOINT_EXISTS_ERROR_CODES = [401, 403, 405];
* activation be picked up without forcing the user to clear storage.
*/
const LICENSE_CACHE_PREFIX = "netbird-licensed:";
const LICENSE_CACHE_TTL_MS = 60 * 60 * 1000;
const LICENSE_CACHE_TTL_MS = 60 * 10 * 1000;
const licenseCacheKey = () => `${LICENSE_CACHE_PREFIX}${loadConfig().apiOrigin}`;
+8
View File
@@ -33,12 +33,20 @@ export const CrowdSecMode = {
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
export const AllowMatch = {
ALL: "all",
ANY: "any",
} as const;
export type AllowMatch = (typeof AllowMatch)[keyof typeof AllowMatch];
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode;
allow_match?: AllowMatch;
}
export interface ReverseProxyMeta {
+103 -5
View File
@@ -113,6 +113,8 @@ function upstreamUrlPlaceholder(providerId: AIProviderId): string {
return "https://api.portkey.ai";
case "vllm":
return "https://your-vllm-host:8000";
case "kimi_api":
return "https://api.moonshot.ai";
case "custom":
return "https://your-llm-host";
default:
@@ -217,6 +219,9 @@ export default function AIProviderModal({
const [skipTlsVerification, setSkipTlsVerification] = useState<boolean>(
provider?.skipTlsVerification ?? false,
);
const [metadataDisabled, setMetadataDisabled] = useState<boolean>(
provider?.metadataDisabled ?? false,
);
const catalog = getById(providerId);
// Custom-kind providers (the generic "Custom" entry and named self-hosted
@@ -262,7 +267,8 @@ export default function AIProviderModal({
providerId === "bifrost" ||
providerId === "cloudflare_ai_gateway" ||
providerId === "vercel_ai_gateway" ||
providerId === "openrouter";
providerId === "openrouter" ||
providerId === "bedrock_api";
// If the user flips provider type while viewing the Mappings tab and
// the new type doesn't show mappings, snap back to the Provider tab
@@ -340,6 +346,7 @@ export default function AIProviderModal({
setIdentityHeaderUserId(provider.identityHeaderUserId ?? "");
setIdentityHeaderGroups(provider.identityHeaderGroups ?? "");
setSkipTlsVerification(provider.skipTlsVerification ?? false);
setMetadataDisabled(provider.metadataDisabled ?? false);
} else {
const fallback = getById("openai_api");
setProviderId("openai_api");
@@ -354,6 +361,7 @@ export default function AIProviderModal({
setIdentityHeaderUserId("");
setIdentityHeaderGroups("");
setSkipTlsVerification(false);
setMetadataDisabled(false);
}
};
@@ -405,6 +413,7 @@ export default function AIProviderModal({
extraValues: sanitizedExtraValues,
...identityOverrides,
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
metadataDisabled,
// Only forward the API key when the user actually rotated it
...(apiKey && apiKey !== "••••••••" ? { apiKey } : {}),
});
@@ -420,6 +429,7 @@ export default function AIProviderModal({
extraValues: sanitizedExtraValues,
...identityOverrides,
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
metadataDisabled,
models,
enabled: true,
});
@@ -609,7 +619,20 @@ export default function AIProviderModal({
</FormRow>
<FormRow
label={"Upstream URL"}
label={
providerId === "kimi_api" ? (
<>
Upstream URL
<HelpTooltip
content={
"Moonshot AI's international platform endpoint. Keep the bare host. Moonshot serves both API shapes with the same key: the path an agent calls rides through to Moonshot, so its base URL picks the shape (Claude Code appends /anthropic; Kimi CLI and OpenAI shaped callers use the bare endpoint). Mainland China accounts use api.moonshot.cn instead."
}
/>
</>
) : (
"Upstream URL"
)
}
helpText={upstreamUrlHelpText(providerId)}
>
<Input
@@ -730,7 +753,7 @@ export default function AIProviderModal({
onChange={(e) => setApiKey(e.target.value)}
customPrefix={<KeyRound size={14} />}
placeholder={
providerId === "openai_api"
providerId === "openai_api" || providerId === "kimi_api"
? "sk-..."
: providerId === "anthropic_api"
? "sk-ant-..."
@@ -785,6 +808,23 @@ export default function AIProviderModal({
{showMappings && providerId === "litellm_proxy" && (
<TabsContent value={"mappings"} className={"pb-8"}>
<div className={"px-8 pt-3 flex-col flex gap-4"}>
{/* The forwarding toggle sits first: it gates the identity
mappings described below, so turning it off makes the fixed
mapping that follows moot. */}
<FancyToggleSwitch
value={!metadataDisabled}
onChange={(v) => setMetadataDisabled(!v)}
label={
<>
<ArrowRightLeft size={15} />
Forward Identity Metadata
</>
}
helpText={
"Stamp the identity mappings below onto LiteLLM requests."
}
/>
<div>
<Label>Identity Mappings</Label>
<HelpText className={"mb-0"}>
@@ -987,6 +1027,61 @@ export default function AIProviderModal({
</TabsContent>
)}
{showMappings && providerId === "bedrock_api" && (
<TabsContent value={"mappings"} className={"pb-8"}>
<div className={"px-8 pt-3 flex-col flex gap-4"}>
{/* The forwarding toggle sits first: it gates the identity
metadata described below, so turning it off makes the fixed
mapping that follows moot. */}
<FancyToggleSwitch
value={!metadataDisabled}
onChange={(v) => setMetadataDisabled(!v)}
label={
<>
<ArrowRightLeft size={15} />
Forward Identity Metadata
</>
}
helpText={
"Stamp the identity metadata below onto Bedrock requests."
}
/>
<div>
<Label>Identity Metadata</Label>
<HelpText className={"mb-0"}>
NetBird stamps the caller&apos;s identity into the{" "}
<InlineLink
href={
"https://docs.aws.amazon.com/bedrock/latest/userguide/cost-mgmt-request-metadata.html"
}
target={"_blank"}
>
<code
className={
"text-xs font-mono bg-nb-gray-900/60 rounded px-1.5 py-0.5"
}
>
X-Amzn-Bedrock-Request-Metadata
</code>
</InlineLink>{" "}
header, so you can break Bedrock spend down by user and
group. Client-supplied values are stripped and sanitized.
</HelpText>
</div>
<div
className={
"rounded-md overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30"
}
>
<MappingRow header={"user"} sourceLabel={"User Email"} />
<MappingRow header={"group"} sourceLabel={"Groups"} />
</div>
</div>
</TabsContent>
)}
{showMappings && providerId === "vercel_ai_gateway" && (
<TabsContent value={"mappings"} className={"pb-8"}>
<div className={"px-8 pt-3 flex-col flex gap-4"}>
@@ -1164,8 +1259,11 @@ export default function AIProviderModal({
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={"https://docs.netbird.io/"} target={"_blank"}>
Agent Network
<InlineLink
href={"https://docs.netbird.io/agent-network/providers"}
target={"_blank"}
>
Agent Network Providers
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
@@ -50,6 +50,9 @@ export type APIProvider = {
// Skip TLS certificate verification on upstream requests (custom providers
// with self-signed certs). Off by default.
skip_tls_verification?: boolean;
// Disable identity metadata injection (caller user + authorizing group) on
// upstream requests. Off by default (metadata is sent).
metadata_disabled?: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
@@ -67,6 +70,7 @@ export type APIProviderRequest = {
identity_header_user_id?: string;
identity_header_groups?: string;
skip_tls_verification?: boolean;
metadata_disabled?: boolean;
models: APIProviderModel[];
enabled?: boolean;
};
@@ -84,6 +88,8 @@ export type ProviderConnectInput = {
identityHeaderGroups?: string;
// Skip upstream TLS verification (custom providers with self-signed certs).
skipTlsVerification?: boolean;
// Disable identity metadata injection (user + authorizing group). Off by default.
metadataDisabled?: boolean;
models: ProviderModel[];
enabled?: boolean;
};
@@ -97,6 +103,7 @@ export type ProviderUpdateInput = {
identityHeaderUserId?: string;
identityHeaderGroups?: string;
skipTlsVerification?: boolean;
metadataDisabled?: boolean;
models?: ProviderModel[];
enabled?: boolean;
};
@@ -159,6 +166,7 @@ function fromAPI(p: APIProvider): AIProvider {
identityHeaderUserId: p.identity_header_user_id,
identityHeaderGroups: p.identity_header_groups,
skipTlsVerification: p.skip_tls_verification ?? false,
metadataDisabled: p.metadata_disabled ?? false,
status: p.enabled ? "active" : "disabled",
models,
allowedGroups: [],
@@ -201,6 +209,7 @@ function toCreateRequest(input: ProviderConnectInput): APIProviderRequest {
identity_header_user_id: input.identityHeaderUserId,
identity_header_groups: input.identityHeaderGroups,
skip_tls_verification: input.skipTlsVerification,
metadata_disabled: input.metadataDisabled,
models: toAPIModels(input.models),
enabled: input.enabled,
};
@@ -647,6 +656,8 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
updates.identityHeaderGroups ?? existing.identity_header_groups,
skip_tls_verification:
updates.skipTlsVerification ?? existing.skip_tls_verification,
metadata_disabled:
updates.metadataDisabled ?? existing.metadata_disabled,
models: updates.models
? toAPIModels(updates.models)
: existing.models ?? [],
+138 -7
View File
@@ -2,6 +2,7 @@
import Code from "@components/Code";
import { Modal, ModalContent } from "@components/modal/Modal";
import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider";
import Paragraph from "@components/Paragraph";
import SmallParagraph from "@components/SmallParagraph";
import SquareIcon from "@components/SquareIcon";
@@ -56,6 +57,7 @@ export function AgentConnectTabs({
listClassName = "px-8",
contentClassName = "px-6 py-2",
defaultTab = "claude-code",
providerIds = [],
}: {
endpoint: string;
listClassName?: string;
@@ -64,23 +66,39 @@ export function AgentConnectTabs({
// matching tool (e.g. Anthropic → claude-code, OpenAI → curl). Keyed below
// so a late-resolving defaultTab still re-initialises the tabs.
defaultTab?: string;
// Catalog ids of the account's connected providers. Kimi-specific config
// surfaces (the Kimi CLI tab and the Kimi variant inside the Claude Code
// tab) only render when a kimi_api provider is actually connected —
// showing Moonshot setup against an endpoint that can't route to Kimi
// would just be a trap.
providerIds?: string[];
}) {
const baseUrl = `https://${endpoint}`;
const openaiBase = `${baseUrl}/v1`;
const hasKimi = providerIds.includes("kimi_api");
const [claudeMode, setClaudeMode] = React.useState<"config" | "shell">(
"config",
);
// Which backend the Claude Code config targets — Anthropic API direct, or
// via Vertex AI / Bedrock. Switched in-tab instead of separate tabs.
// Which backend the Claude Code config targets — Anthropic API direct,
// via Vertex AI / Bedrock, or Kimi (Moonshot AI, whose upstream speaks the
// Anthropic Messages API too). Switched in-tab instead of separate tabs.
// When Kimi is the only Anthropic-shaped provider connected, it's the only
// config that can work, so start there.
const kimiOnlyAnthropicShape =
hasKimi &&
!["anthropic_api", "vertex_ai_api", "bedrock_api"].some((id) =>
providerIds.includes(id),
);
const [claudeProvider, setClaudeProvider] = React.useState<
"anthropic" | "vertex" | "bedrock"
>("anthropic");
"anthropic" | "vertex" | "bedrock" | "kimi"
>(kimiOnlyAnthropicShape ? "kimi" : "anthropic");
return (
<Tabs key={defaultTab} defaultValue={defaultTab} className={"mt-2"}>
<TabsList justify={"start"} className={listClassName}>
<TabsTrigger value={"claude-code"}>Claude Code</TabsTrigger>
<TabsTrigger value={"codex"}>Codex</TabsTrigger>
{hasKimi && <TabsTrigger value={"kimi-cli"}>Kimi CLI</TabsTrigger>}
<TabsTrigger value={"openai-sdk"}>OpenAI SDK</TabsTrigger>
<TabsTrigger value={"curl"}>cURL</TabsTrigger>
</TabsList>
@@ -91,15 +109,20 @@ export function AgentConnectTabs({
<SelectDropdown
value={claudeProvider}
onChange={(v) =>
setClaudeProvider(v as "anthropic" | "vertex" | "bedrock")
setClaudeProvider(
v as "anthropic" | "vertex" | "bedrock" | "kimi",
)
}
options={[
{ label: "Anthropic API", value: "anthropic" },
{ label: "Vertex AI", value: "vertex" },
{ label: "Bedrock", value: "bedrock" },
...(hasKimi
? [{ label: "Kimi (Moonshot AI)", value: "kimi" }]
: []),
]}
showValues={false}
className={"w-[160px]"}
className={"!w-auto min-w-[160px]"}
/>
</div>
@@ -176,6 +199,76 @@ export function AgentConnectTabs({
]}
/>
)}
{claudeProvider === "kimi" && (
<>
<div className={"flex items-center justify-between gap-3 mb-2"}>
<SmallParagraph className={"!mb-0"}>
{claudeMode === "config"
? "Add to ~/.claude/settings.json:"
: "Run in your shell:"}
</SmallParagraph>
<button
type={"button"}
onClick={() =>
setClaudeMode(claudeMode === "config" ? "shell" : "config")
}
className={
"shrink-0 mr-2 text-[11px] text-white hover:underline underline-offset-2 cursor-pointer"
}
>
{claudeMode === "config" ? "Shell" : "JSON"}
</button>
</div>
<Snippet
// Claude Code speaks the Anthropic Messages API, which
// Moonshot serves under the /anthropic path prefix. The
// prefix goes in the agent's base URL and rides through the
// endpoint to the bare https://api.moonshot.ai upstream, so
// one Kimi provider serves both API shapes. Every model slot
// Claude Code fills on its own (opus/sonnet/haiku tiers,
// subagents) is pinned to kimi-k3 so no Claude model names
// leak into requests the upstream can't serve, and tool
// search is off because Moonshot rejects its tool_reference
// blocks — both per Moonshot's Claude Code guide.
lines={
claudeMode === "config"
? [
`{`,
` "apiKeyHelper": "echo '-'",`,
` "env": {`,
` "ANTHROPIC_BASE_URL": "${baseUrl}/anthropic",`,
` "ANTHROPIC_MODEL": "kimi-k3",`,
` "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k3",`,
` "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k3",`,
` "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-k3",`,
` "CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k3",`,
` "ENABLE_TOOL_SEARCH": "false"`,
` }`,
`}`,
]
: [
`export ANTHROPIC_BASE_URL=${baseUrl}/anthropic`,
`export ANTHROPIC_API_KEY=none`,
`export ANTHROPIC_MODEL=kimi-k3`,
`export ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k3`,
`export ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k3`,
`export ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k3`,
`export CLAUDE_CODE_SUBAGENT_MODEL=kimi-k3`,
`export ENABLE_TOOL_SEARCH=false`,
`claude`,
]
}
/>
<SmallParagraph className={"mt-3"}>
Pairs with a Kimi provider keeping the default upstream URL{" "}
<code className={"font-mono"}>https://api.moonshot.ai</code>.
The <code className={"font-mono"}>/anthropic</code> suffix in
the base URL rides through the endpoint to Moonshot, which
serves the Anthropic Messages API under that path.
</SmallParagraph>
</>
)}
</div>
</TabsContent>
@@ -195,6 +288,38 @@ export function AgentConnectTabs({
</div>
</TabsContent>
<TabsContent value={"kimi-cli"}>
<div className={contentClassName} hidden={!hasKimi}>
<Snippet
// Kimi CLI reads providers from ~/.kimi/config.toml. Unlike
// Claude Code, its "anthropic" provider type needs the bare
// endpoint — no /anthropic prefix in base_url; api_key is a
// placeholder since NetBird injects the real key server-side.
caption={"Add to ~/.kimi/config.toml:"}
lines={[
`default_model = "kimi-k3"`,
``,
`[providers.netbird]`,
`type = "anthropic"`,
`base_url = "${baseUrl}"`,
`api_key = "-"`,
``,
`[models.kimi-k3]`,
`provider = "netbird"`,
`model = "kimi-k3"`,
`max_context_size = 1000000`,
]}
/>
<SmallParagraph className={"mt-3"}>
Pairs with a Kimi provider keeping the default upstream URL{" "}
<code className={"font-mono"}>https://api.moonshot.ai</code>. For
the OpenAI shape instead, use{" "}
<code className={"font-mono"}>type = &quot;openai_legacy&quot;</code>{" "}
with <code className={"font-mono"}>base_url = &quot;{openaiBase}&quot;</code>.
</SmallParagraph>
</div>
</TabsContent>
<TabsContent value={"openai-sdk"}>
<div className={contentClassName}>
<Snippet
@@ -243,6 +368,9 @@ export default function AgentConnectModal({
onOpenChange,
endpoint,
}: Readonly<Props>) {
// Rendered inside <AIProvidersProvider> (providers page); the connected
// provider ids gate which per-tool config variants the tabs offer.
const { providers } = useAIProviders();
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass={"max-w-2xl"}>
@@ -258,7 +386,10 @@ export default function AgentConnectModal({
</Paragraph>
</div>
<AgentConnectTabs endpoint={endpoint} />
<AgentConnectTabs
endpoint={endpoint}
providerIds={providers.map((p) => p.providerId)}
/>
</ModalContent>
</Modal>
);
@@ -132,6 +132,7 @@ const KNOWN_PROVIDER_IDS: AIProviderId[] = [
"bedrock_api",
"vertex_ai_api",
"mistral_api",
"kimi_api",
"custom",
];
@@ -12,6 +12,7 @@ export type AIProviderId =
| "bedrock_api"
| "vertex_ai_api"
| "mistral_api"
| "kimi_api"
| "litellm_proxy"
| "portkey"
| "bifrost"
@@ -64,6 +65,9 @@ export type AIProvider = {
// Skip TLS certificate verification on upstream requests — for custom
// providers using self-signed certs. Off by default.
skipTlsVerification?: boolean;
// Disable identity metadata injection (caller user + authorizing group) on
// upstream requests, e.g. AWS Bedrock cost-allocation. Off by default.
metadataDisabled?: boolean;
status: AIProviderStatus;
models: ProviderModel[];
allowedGroups: string[];
@@ -169,8 +169,11 @@ export default function AgentProvidersTable({
learnMore={
<>
Learn more about
<InlineLink href={"https://docs.netbird.io/"} target={"_blank"}>
Agent Network
<InlineLink
href={"https://docs.netbird.io/agent-network/providers"}
target={"_blank"}
>
Agent Network Providers
<ExternalLinkIcon size={12} />
</InlineLink>
</>
+8 -9
View File
@@ -70,8 +70,8 @@ export function IntegrationCard<T>({
)}
>
<div className={"flex flex-col gap-4"}>
<div className={"flex justify-between"}>
<div className={"flex gap-4"}>
<div className={"flex justify-between gap-3"}>
<div className={"flex gap-4 min-w-0"}>
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
@@ -84,25 +84,24 @@ export function IntegrationCard<T>({
priority={true}
/>
</div>
<div>
<h3 className={""}>{name}</h3>
<div className={"min-w-0"}>
<h3 className={"truncate"}>{name}</h3>
<InlineLink
href={url.href}
target={"_blank"}
className={"text-sm font-light"}
className={"text-sm font-light min-w-0 max-w-full"}
variant={"faded"}
>
{url.title}
<ExternalLinkIcon size={12} />
<span className={"truncate"}>{url.title}</span>
<ExternalLinkIcon size={12} className={"shrink-0"} />
</InlineLink>
</div>
</div>
{!hideSwitch && (
<div className={"flex items-center"}>
<div className={"flex items-center shrink-0"}>
<ToggleSwitch
checked={switchState}
onCheckedChange={onEnabledChange}
className={"grow"}
/>
</div>
)}
@@ -15,9 +15,12 @@ type Props = {
export const OnboardingAgentConfigure = ({ onBack, onNext }: Props) => {
const { settings, providers } = useAIProviders();
// Open the tab that matches the connected provider: Anthropic speaks the
// Claude Code config, everything else is OpenAI-shaped so default to cURL.
const defaultTab = providers.some((p) => p.providerId === "anthropic_api")
// Open the tab that matches the connected provider: Anthropic and Kimi
// (whose upstream also speaks the Anthropic Messages API) get the Claude
// Code config, everything else is OpenAI-shaped so default to cURL.
const defaultTab = providers.some(
(p) => p.providerId === "anthropic_api" || p.providerId === "kimi_api",
)
? "claude-code"
: "curl";
@@ -42,6 +45,7 @@ export const OnboardingAgentConfigure = ({ onBack, onNext }: Props) => {
listClassName={"px-0"}
contentClassName={"px-0 py-2"}
defaultTab={defaultTab}
providerIds={providers.map((p) => p.providerId)}
/>
) : (
<div
+39 -39
View File
@@ -3,27 +3,31 @@ import { DropdownMenuItem } from "@components/DropdownMenu";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { CircleHelpIcon, MonitorIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip";
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
type Props = {
peer: Peer;
isDropdown?: boolean;
};
const RDP_SUPPORTED_OS = new Set([
OperatingSystem.WINDOWS,
OperatingSystem.LINUX,
OperatingSystem.FREEBSD,
]);
export const RDPButton = ({ peer, isDropdown = false }: Props) => {
const [modal, setModal] = useState(false);
const { permission } = usePermissions();
const os = getOperatingSystem(peer?.os);
if (!RDP_SUPPORTED_OS.has(os)) return null;
const disabled = !peer.connected || !permission.peers.update;
const hasPermission = permission.peers.update;
const isWindows = getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS;
const openRDPPage = () => {
window.open(
`/peer/rdp?id=${peer.id}`,
@@ -33,40 +37,36 @@ export const RDPButton = ({ peer, isDropdown = false }: Props) => {
};
return (
isWindows && (
<>
<div>
<RDPTooltip
disabled={!disabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
<div>
<RDPTooltip
disabled={!disabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
>
{isDropdown ? (
<DropdownMenuItem
onClick={openRDPPage}
disabled={disabled}
className={"w-full"}
>
{isDropdown ? (
<DropdownMenuItem
onClick={openRDPPage}
disabled={disabled}
className={"w-full"}
>
<div className={"flex gap-3 items-center w-full"}>
<MonitorIcon size={14} className={"shrink-0"} />
RDP
</div>
</DropdownMenuItem>
) : (
<Button
variant="secondary"
size="sm"
onClick={openRDPPage}
disabled={disabled}
>
<MonitorIcon size={16} />
RDP
{disabled && <CircleHelpIcon size={12} />}
</Button>
)}
</RDPTooltip>
</div>
</>
)
<div className={"flex gap-3 items-center w-full"}>
<MonitorIcon size={14} className={"shrink-0"} />
RDP
</div>
</DropdownMenuItem>
) : (
<Button
variant="secondary"
size="sm"
onClick={openRDPPage}
disabled={disabled}
>
<MonitorIcon size={16} />
RDP
{disabled && <CircleHelpIcon size={12} />}
</Button>
)}
</RDPTooltip>
</div>
);
};
@@ -3,6 +3,8 @@ import { useCallback, useMemo, useState } from "react";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { Peer } from "@/interfaces/Peer";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import {
ChevronsLeftRightEllipsis,
ExternalLinkIcon,
@@ -38,7 +40,11 @@ export const RDPCredentialsModal = ({
error,
loading,
}: Props) => {
const [username, setUsername] = useState("Administrator");
const defaultUsername =
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS
? "Administrator"
: "root";
const [username, setUsername] = useState(defaultUsername);
const [password, setPassword] = useState("");
const [port, setPort] = useState("3389");
Loaded 20 of 25 files, more files were not shown because too many files have changed in this diff. Show more