Compare commits

...
5 Commits
6 changed files with 219 additions and 87 deletions

No files matched your search

@@ -193,7 +193,7 @@ export default function AgentNetworkProvidersPage() {
Anthropic through one keyless endpoint, accessible only via
NetBird’s tunnel.
<InlineLink
href={"https://anet.demo.netbird.io/"}
href={"https://docs.netbird.io/agent-network/providers"}
target={"_blank"}
>
Learn more
+2 -9
View File
@@ -1,6 +1,6 @@
import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon";
import AddPeerButton from "@components/ui/AddPeerButton";
import AddPeerDropdown from "@components/ui/AddPeerDropdown";
import GetStartedTest from "@components/ui/GetStartedTest";
import { ExternalLinkIcon } from "lucide-react";
import * as React from "react";
@@ -8,17 +8,10 @@ import PeerIcon from "@/assets/icons/PeerIcon";
type Props = {
showBackground?: boolean;
// When set, tailors the empty-state copy and threads isUserDevice
// through AddPeerButton so the right Install NetBird flow opens:
// true → User Devices empty state (browser/SSO flow, mobile tabs).
// false → Servers empty state (setup-key flow, no mobile tabs).
// undefined → legacy/global empty state (no kind preference).
isUserDevice?: boolean;
};
export const NoPeersGettingStarted = ({
showBackground = true,
isUserDevice,
}: Readonly<Props>) => {
return (
<GetStartedTest
@@ -35,7 +28,7 @@ export const NoPeersGettingStarted = ({
"It looks like you don't have any connected machines.\n" +
"Get started by adding one to your network."
}
button={<AddPeerButton isUserDevice={isUserDevice} />}
button={<AddPeerDropdown />}
learnMore={
<>
Learn more in our{" "}
+117 -36
View File
@@ -28,8 +28,9 @@ import {
PlusCircle,
PlusIcon,
Sparkles,
UploadIcon,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import React, { useMemo, useRef, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import {
ReverseProxyDomain,
@@ -100,6 +101,8 @@ function upstreamUrlPlaceholder(providerId: AIProviderId): string {
return "https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openai";
case "vercel_ai_gateway":
return "https://ai-gateway.vercel.sh";
case "vertex_ai_api":
return "https://aiplatform.googleapis.com";
case "openrouter":
return "https://openrouter.ai/api/v1";
case "litellm_proxy":
@@ -168,6 +171,25 @@ export default function AIProviderModal({
const [apiKey, setApiKey] = useState(isEdit ? "••••••••" : "");
const [bootstrapCluster, setBootstrapCluster] = useState<string>("");
const [models, setModels] = useState<ProviderModel[]>(provider?.models ?? []);
// Vertex AI authenticates with a service-account JSON key, not an API key.
// We upload the file and store it base64-encoded in apiKey (the server
// decodes the base64 JSON); keyFileName tracks the uploaded file for display.
const [keyFileName, setKeyFileName] = useState<string | null>(null);
const keyFileInputRef = useRef<HTMLInputElement>(null);
const onJsonKeyUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
bytes.forEach((b) => (binary += String.fromCharCode(b)));
// The server expects the JSON key prefixed with "keyfile::".
setApiKey("keyfile::" + window.btoa(binary));
setKeyFileName(file.name);
// Clear so picking the same file again still fires onChange.
e.target.value = "";
};
// extraValues holds operator-typed values for catalog-declared extra
// headers (e.g. Portkey's x-portkey-config). Keyed by wire header
// name. Catalog (catalog.extra_headers) decides which inputs render
@@ -267,6 +289,9 @@ export default function AIProviderModal({
if (upstreamUrl) return;
if (!catalog) return;
if (!catalog.default_host) return;
// Vertex's catalog host is templated (<region>-…); skip the prefill so the
// clean https://aiplatform.googleapis.com placeholder shows instead.
if (catalog.id === "vertex_ai_api") return;
setUpstreamUrl(`https://${catalog.default_host}`);
}, [provider, upstreamUrl, catalog]);
@@ -519,6 +544,10 @@ export default function AIProviderModal({
onChange={(v) => {
const next = v as AIProviderId;
setProviderId(next);
// The credential differs per provider (API key vs Vertex
// JSON upload), so clear it when switching.
setApiKey("");
setKeyFileName(null);
const c = getById(next);
if (c) {
setName(c.name);
@@ -527,7 +556,11 @@ export default function AIProviderModal({
// endpoint). Don't pre-fill "https://" — let the
// placeholder hint them what to type instead.
setUpstreamUrl(
c.default_host ? `https://${c.default_host}` : "",
next === "vertex_ai_api"
? "https://aiplatform.googleapis.com"
: c.default_host
? `https://${c.default_host}`
: "",
);
setModels([]);
// Auto-seed the identity inputs from the
@@ -571,41 +604,89 @@ export default function AIProviderModal({
/>
</FormRow>
<FormRow
label={
<>
Provider API key
<HelpTooltip
content={
<>
NetBird injects it as{" "}
<code className={"text-nb-gray-200"}>
{catalog?.auth_header_template}
</code>{" "}
on every upstream request, so agents never see the
key.
</>
}
/>
</>
}
helpText={"The API key issued by the provider."}
>
<Input
type={"password"}
showPasswordToggle
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
customPrefix={<KeyRound size={14} />}
placeholder={
providerId === "openai_api"
? "sk-..."
: providerId === "anthropic_api"
? "sk-ant-..."
: "Paste your API key"
{providerId === "vertex_ai_api" ? (
<FormRow
label={
<>
Service account JSON key
<HelpTooltip
content={
<>
Upload the Vertex AI service account JSON key.
NetBird base64-encodes it and prefixes it with{" "}
<code className={"text-nb-gray-200"}>keyfile::</code>{" "}
before injecting it on every upstream request, so
agents never see the key.
</>
}
/>
</>
}
/>
</FormRow>
helpText={"Upload the service account JSON key file."}
>
<div className={"flex items-center gap-3"}>
<Button
variant={"secondary"}
onClick={() => keyFileInputRef.current?.click()}
>
<UploadIcon size={14} />
{keyFileName || (isEdit && apiKey === "••••••••")
? "Replace JSON key"
: "Upload JSON key"}
</Button>
<span className={"text-xs text-nb-gray-300 truncate"}>
{keyFileName
? keyFileName
: isEdit && apiKey === "••••••••"
? "A key is already stored"
: "No file selected"}
</span>
<input
ref={keyFileInputRef}
type={"file"}
accept={"application/json,.json"}
className={"hidden"}
onChange={onJsonKeyUpload}
/>
</div>
</FormRow>
) : (
<FormRow
label={
<>
Provider API key
<HelpTooltip
content={
<>
NetBird injects it as{" "}
<code className={"text-nb-gray-200"}>
{catalog?.auth_header_template}
</code>{" "}
on every upstream request, so agents never see the
key.
</>
}
/>
</>
}
helpText={"The API key issued by the provider."}
>
<Input
type={"password"}
showPasswordToggle
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
customPrefix={<KeyRound size={14} />}
placeholder={
providerId === "openai_api"
? "sk-..."
: providerId === "anthropic_api"
? "sk-ant-..."
: "Paste your API key"
}
/>
</FormRow>
)}
{(catalog?.extra_headers ?? []).map((h) => {
const ui = EXTRA_HEADER_UI[h.name] ?? fallbackExtraHeaderUI(h.name);
return (
+93 -34
View File
@@ -5,6 +5,7 @@ import { Modal, ModalContent } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import SmallParagraph from "@components/SmallParagraph";
import SquareIcon from "@components/SquareIcon";
import { SelectDropdown } from "@components/select/SelectDropdown";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Plug } from "lucide-react";
import * as React from "react";
@@ -69,6 +70,11 @@ export function AgentConnectTabs({
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.
const [claudeProvider, setClaudeProvider] = React.useState<
"anthropic" | "vertex" | "bedrock"
>("anthropic");
return (
<Tabs key={defaultTab} defaultValue={defaultTab} className={"mt-2"}>
@@ -81,42 +87,95 @@ export function AgentConnectTabs({
<TabsContent value={"claude-code"}>
<div className={contentClassName}>
<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")
<div className={"mb-3"}>
<SelectDropdown
value={claudeProvider}
onChange={(v) =>
setClaudeProvider(v as "anthropic" | "vertex" | "bedrock")
}
className={
"shrink-0 mr-2 text-[11px] text-white hover:underline underline-offset-2 cursor-pointer"
}
>
{claudeMode === "config" ? "Shell" : "JSON"}
</button>
options={[
{ label: "Anthropic API", value: "anthropic" },
{ label: "Vertex AI", value: "vertex" },
{ label: "Bedrock", value: "bedrock" },
]}
showValues={false}
className={"w-[160px]"}
/>
</div>
<Snippet
lines={
claudeMode === "config"
? [
`{`,
` "apiKeyHelper": "echo '-'",`,
` "env": {`,
` "ANTHROPIC_BASE_URL": "${baseUrl}"`,
` }`,
`}`,
]
: [
`export ANTHROPIC_BASE_URL=${baseUrl}`,
`export ANTHROPIC_API_KEY=none`,
`claude`,
]
}
/>
{claudeProvider === "anthropic" && (
<>
<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
lines={
claudeMode === "config"
? [
`{`,
` "apiKeyHelper": "echo '-'",`,
` "env": {`,
` "ANTHROPIC_BASE_URL": "${baseUrl}"`,
` }`,
`}`,
]
: [
`export ANTHROPIC_BASE_URL=${baseUrl}`,
`export ANTHROPIC_API_KEY=none`,
`claude`,
]
}
/>
</>
)}
{claudeProvider === "vertex" && (
<Snippet
caption={"Add to ~/.claude/settings.json:"}
lines={[
`{`,
` "env": {`,
` "CLOUD_ML_REGION": "global",`,
` "ANTHROPIC_VERTEX_PROJECT_ID": "<your-gcp-project-id>",`,
` "CLAUDE_CODE_USE_VERTEX": "1",`,
` "CLAUDE_CODE_SKIP_VERTEX_AUTH": "1",`,
` "ANTHROPIC_VERTEX_BASE_URL": "${baseUrl}/v1"`,
` }`,
`}`,
]}
/>
)}
{claudeProvider === "bedrock" && (
<Snippet
caption={"Add to ~/.claude/settings.json:"}
lines={[
`{`,
` "env": {`,
` "ANTHROPIC_MODEL": "<your-bedrock-model-id>",`,
` "ANTHROPIC_BEDROCK_BASE_URL": "${baseUrl}/bedrock",`,
` "CLAUDE_CODE_USE_BEDROCK": "1",`,
` "CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1"`,
` }`,
`}`,
]}
/>
)}
</div>
</TabsContent>
+1 -4
View File
@@ -518,10 +518,7 @@ export default function PeersTable({
// still shows.
hasServerSideFilters={kind !== undefined && (peers?.length ?? 0) > 0}
getStartedCard={
<NoPeersGettingStarted
showBackground={true}
isUserDevice={kind ? kind === "users" : undefined}
/>
<NoPeersGettingStarted showBackground={true} />
}
rightSide={() => (
<>
+5 -3
View File
@@ -129,17 +129,19 @@ export default function useFetchApi<T>(
const handleErrors = useApiErrorHandling(ignoreError);
const { globalApiParams } = useApplicationContext();
const cacheKey = options?.key ? [url, options?.key] : url;
const cacheKey = !allowFetch
? null
: options?.key
? [url, options?.key]
: url;
const fetchFn = options?.key
? async ([url]: [url: string]) => {
if (!allowFetch) return;
return apiRequest<T>(fetch, "GET", url, undefined, {
...options,
globalParams: globalApiParams,
}).catch((err) => handleErrors(err as ErrorResponse));
}
: async (url: string) => {
if (!allowFetch) return;
return apiRequest<T>(fetch, "GET", url, undefined, {
...options,
globalParams: globalApiParams,