diff --git a/src/modules/agent-network/AIProviderModal.tsx b/src/modules/agent-network/AIProviderModal.tsx index 4716dee1e..75f0600ab 100644 --- a/src/modules/agent-network/AIProviderModal.tsx +++ b/src/modules/agent-network/AIProviderModal.tsx @@ -19,6 +19,7 @@ import Paragraph from "@components/Paragraph"; import { SelectDropdown } from "@components/select/SelectDropdown"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; import useFetchApi from "@utils/api"; +import { cn } from "@utils/helpers"; import { AlertCircleIcon, ArrowRightLeft, @@ -30,25 +31,26 @@ import { MinusCircleIcon, PlusCircle, PlusIcon, + RefreshCwIcon, ShieldOffIcon, Sparkles, UploadIcon, } from "lucide-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon"; +import { useDialog } from "@/contexts/DialogProvider"; import { ReverseProxyDomain, ReverseProxyDomainType, } from "@/interfaces/ReverseProxy"; +import AIProviderLogo from "@/modules/agent-network/AIProviderLogo"; +import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider"; import { AIProvider, AIProviderId, ProviderModel, } from "@/modules/agent-network/data/mockData"; -import AIProviderLogo from "@/modules/agent-network/AIProviderLogo"; -import { - useAIProviders, -} from "@/modules/agent-network/AIProvidersProvider"; +import { useDiscoveredModels } from "@/modules/agent-network/useDiscoveredModels"; import { useProviderCatalog } from "@/modules/agent-network/useProviderCatalog"; // EXTRA_HEADER_UI owns the dashboard copy for catalog-declared extra @@ -160,11 +162,22 @@ type Props = { type EditableModel = ProviderModel & { _key: string }; let modelKeySeq = 0; +// MASKED_API_KEY is what the edit form shows in place of a stored credential. +// The real key never reaches the browser, so anything equal to this is a +// placeholder rather than something that can be sent to a vendor. +const MASKED_API_KEY = "••••••••"; + const withModelKey = (m: ProviderModel): EditableModel => ({ ...m, _key: `model-${modelKeySeq++}`, }); +// hasNoPrice reports a row that would meter every request against it as free. +// Both rates, not either: a model priced on input alone is a deliberate +// configuration, while zero on both is the shape an unpriced model arrives in. +const hasNoPrice = (m: ProviderModel) => !m.inputPer1k && !m.outputPer1k; + + export default function AIProviderModal({ open, onOpenChange, @@ -180,6 +193,7 @@ export default function AIProviderModal({ ReverseProxyDomain[] >("/reverse-proxies/domains"); const { catalog: catalogList, getById } = useProviderCatalog(); + const { confirm } = useDialog(); const isEdit = !!provider; // The endpoint lives on the account-level Settings row, bootstrapped once @@ -196,11 +210,12 @@ export default function AIProviderModal({ const [upstreamUrl, setUpstreamUrl] = useState( provider?.upstreamUrl ?? "", ); - const [apiKey, setApiKey] = useState(isEdit ? "••••••••" : ""); + const [apiKey, setApiKey] = useState(isEdit ? MASKED_API_KEY : ""); const [bootstrapCluster, setBootstrapCluster] = useState(""); const [models, setModels] = useState(() => (provider?.models ?? []).map(withModelKey), ); + const discovered = useDiscoveredModels(); // 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 @@ -361,7 +376,7 @@ export default function AIProviderModal({ setProviderId(provider.providerId); setName(provider.name); setUpstreamUrl(provider.upstreamUrl); - setApiKey("••••••••"); + setApiKey(MASKED_API_KEY); setBootstrapCluster(""); setModels(provider.models.map(withModelKey)); setExtraValues(provider.extraValues ?? {}); @@ -373,7 +388,9 @@ export default function AIProviderModal({ const fallback = getById("openai_api"); setProviderId("openai_api"); setName(fallback ? fallback.name : "OpenAI API"); - setUpstreamUrl(fallback?.default_host ? `https://${fallback.default_host}` : ""); + setUpstreamUrl( + fallback?.default_host ? `https://${fallback.default_host}` : "", + ); setApiKey(""); setBootstrapCluster( settingsBootstrapped ? "" : validatedClusters[0]?.domain ?? "", @@ -430,6 +447,27 @@ export default function AIProviderModal({ seenModelIds.add(m.id); return true; }); + + // Saving an unpriced model is silent and irreversible in effect: every + // request against it records $0, and the usage that was already spent + // cannot be re-priced afterwards. The inline warning is easy to scroll + // past on a long vendor list, so confirm at the point of no return. + const unpriced = submittedModels.filter(hasNoPrice); + if (unpriced.length > 0) { + const proceed = await confirm({ + title: + unpriced.length === 1 + ? "Save with 1 unpriced model?" + : `Save with ${unpriced.length} unpriced models?`, + description: + "Models without rates are tracked at $0 and don’t count toward " + + "budget limits. Set rates now or later.", + confirmText: "Save anyway", + cancelText: "Set rates first", + type: "warning", + }); + if (!proceed) return; + } // Identity overrides are only forwarded when the catalog entry // flags either shape (HeaderPair or JSONMetadata) as customizable. // Sending them on a non-customizable provider would be a no-op @@ -452,7 +490,7 @@ export default function AIProviderModal({ skipTlsVerification: isCustomKind ? skipTlsVerification : false, metadataDisabled, // Only forward the API key when the user actually rotated it - ...(apiKey && apiKey !== "••••••••" ? { apiKey } : {}), + ...(apiKey && apiKey.trim() !== MASKED_API_KEY ? { apiKey } : {}), }); handleClose(); return; @@ -525,11 +563,102 @@ export default function AIProviderModal({ // Catalog options the user hasn't already added; falls back to a // generic empty row when the catalog is exhausted or there is no // catalog (custom providers). - const catalogModelOptions = useMemo( - () => catalog?.models ?? [], - [catalog], + // Merged: the catalog first, then anything the vendor reported that the + // catalog does not already carry. Both the per-row picker and "Add More" + // read this one list, so merging here is all the wiring either needs. + // + // A catalog entry wins on collision — it carries prices, and the discovery + // response deliberately carries none. + const catalogModelOptions = useMemo(() => { + const base = catalog?.models ?? []; + if (discovered.models.length === 0) return base; + + const known = new Set(base.map((m) => m.id)); + const extra = discovered.models + .filter((m) => !known.has(m.id)) + .map((m) => ({ + id: m.id, + label: m.label || m.id, + input_per_1k: 0, + output_per_1k: 0, + pricing_known: m.pricing_known, + })); + return [...base, ...extra]; + }, [catalog, discovered.models]); + + // Editing a saved provider shows a masked api key, never the real one, so + // discovery reuses the stored credential by record id instead. A new + // provider has to supply the key the operator is typing. + // + // That reuse is only right while the form still describes the record the + // credential belongs to. The API resolves a provider_id request entirely + // from the stored row — vendor, upstream and key — so switching the vendor + // dropdown and then asking by record id answers with the OLD vendor's models + // and offers them for the new one. A replacement key typed over the mask is + // the same mistake in the other direction: the operator wants that key + // tested, not the one already saved. + // + // Changing the upstream URL invalidates the saved path: discovery sends + // provider_id and the API resolves the URL from the stored row, so a changed + // URL would silently test the old endpoint. Require a freshly entered key + // when the URL differs; canDiscoverModels will block discovery until the + // operator provides one. + const useSavedCredential = + isEdit && + !!provider?.id && + providerId === provider.providerId && + upstreamUrl === provider.upstreamUrl && + apiKey.trim() === MASKED_API_KEY; + + const canDiscoverModels = useMemo(() => { + if (useSavedCredential) return true; + return ( + upstreamUrl.trim() !== "" && + apiKey.trim() !== "" && + // Compared trimmed on both sides: an untrimmed compare lets a padded + // mask through, and the request then sends the mask as the credential. + apiKey.trim() !== MASKED_API_KEY + ); + }, [useSavedCredential, upstreamUrl, apiKey]); + + const loadModelsFromProvider = async () => { + const found = await discovered.discover( + useSavedCredential && provider?.id + ? { catalog_provider_id: providerId, provider_id: provider.id } + : { + catalog_provider_id: providerId, + upstream_url: upstreamUrl.trim(), + api_key: apiKey.trim(), + }, + ); + }; + + // A discovery result describes one provider, endpoint and credential. Once + // any of those changes on screen, the previous answer is about a + // configuration that is no longer being edited, so it is dropped rather than + // left populating the model picker — whose ids are what save() registers. + // reset also invalidates any request still in flight. + const resetDiscovered = discovered.reset; + useEffect(() => { + resetDiscovered(); + }, [resetDiscovered, providerId, upstreamUrl, apiKey, open]); + + // Rows carrying no price at all. Saving one records every request against + // that model as free, so the row is outlined and a single line says so — + // derived from the rates actually on the form rather than from the discovery + // response, so the warning clears the moment the operator types a rate, and + // covers a hand-added row just as well as a discovered one. + const unpricedModelIds = useMemo( + () => + new Set( + models.filter((m) => m.id !== "" && hasNoPrice(m)).map((m) => m.id), + ), + [models], + ); + const usedModelIds = useMemo( + () => new Set(models.map((m) => m.id)), + [models], ); - const usedModelIds = useMemo(() => new Set(models.map((m) => m.id)), [models]); const addModel = () => { const next = catalogModelOptions.find((m) => !usedModelIds.has(m.id)); if (next) { @@ -587,10 +716,7 @@ export default function AIProviderModal({ Provider - + Models @@ -620,15 +746,16 @@ export default function AIProviderModal({ No active proxy clusters are available. Connect at least one proxy under - {" "}Reverse Proxy - - {" "}before adding a provider. + {" "} + Reverse Proxy + {" "} + before adding a provider. )} - - - Upstream URL - - - ) : ( - "Upstream URL" - ) - } - helpText={upstreamUrlHelpText(providerId)} - > - setUpstreamUrl(e.target.value)} - placeholder={upstreamUrlPlaceholder(providerId)} - /> - + setUpstreamUrl(e.target.value)} + placeholder={upstreamUrlPlaceholder(providerId)} + /> {isCustomKind && ( Upload the Vertex AI service account JSON key. NetBird base64-encodes it and prefixes it with{" "} - keyfile::{" "} + + keyfile:: + {" "} before injecting it on every upstream request, so agents never see the key. @@ -771,14 +881,14 @@ export default function AIProviderModal({ onClick={() => keyFileInputRef.current?.click()} > - {keyFileName || (isEdit && apiKey === "••••••••") + {keyFileName || (isEdit && apiKey === MASKED_API_KEY) ? "Replace JSON key" : "Upload JSON key"} {keyFileName ? keyFileName - : isEdit && apiKey === "••••••••" + : isEdit && apiKey === MASKED_API_KEY ? "A key is already stored" : "No file selected"} @@ -829,7 +939,8 @@ export default function AIProviderModal({ )} {(catalog?.extra_headers ?? []).map((h) => { - const ui = EXTRA_HEADER_UI[h.name] ?? fallbackExtraHeaderUI(h.name); + const ui = + EXTRA_HEADER_UI[h.name] ?? fallbackExtraHeaderUI(h.name); return ( ); })} - - setName(e.target.value)} - placeholder={"e.g. OpenAI"} - /> - + + setName(e.target.value)} + placeholder={"e.g. OpenAI"} + /> + @@ -902,8 +1013,8 @@ export default function AIProviderModal({ > metadata.tags {" "} - in the JSON body so LiteLLM can enforce tag budgets and rate limits. - The user identity is sent in the{" "} + in the JSON body so LiteLLM can enforce tag budgets and rate + limits. The user identity is sent in the{" "} x-litellm-end-user-id {" "} - header. The proxy strips any client-supplied value - first, so an app can't spoof identity. The - configured API key must be a LiteLLM virtual key - with{" "} + header. The proxy strips any client-supplied value first, so + an app can't spoof identity. The configured API key + must be a LiteLLM virtual key with{" "} - Pick which wire headers carry the caller's identity - on every upstream request. The proxy strips any - client-supplied value first, so an app can't spoof - identity. Leave a field empty to disable stamping for that - dimension. The defaults shown as placeholders use the{" "} + Pick which wire headers carry the caller's identity on + every upstream request. The proxy strips any client-supplied + value first, so an app can't spoof identity. Leave a + field empty to disable stamping for that dimension. The + defaults shown as placeholders use the{" "} x-bf-dim-* {" "} - family (Prometheus / OTEL — requires a matching - declaration in your gateway's{" "} + family (Prometheus / OTEL — requires a matching declaration + in your gateway's{" "} x-bf-lh-* {" "} - to use Bifrost's always-on log-metadata path - instead — no gateway-side config needed there. + to use Bifrost's always-on log-metadata path instead — + no gateway-side config needed there. setIdentityHeaderUserId(e.target.value)} - placeholder={identityDefaultUser || "x-bf-dim-netbird_user_id"} + placeholder={ + identityDefaultUser || "x-bf-dim-netbird_user_id" + } /> setIdentityHeaderGroups(e.target.value)} - placeholder={identityDefaultGroups || "x-bf-dim-netbird_groups"} + placeholder={ + identityDefaultGroups || "x-bf-dim-netbird_groups" + } /> @@ -1024,18 +1142,20 @@ export default function AIProviderModal({ {jsonMetadataHeader || "metadata"} {" "} header with the caller's identity so the gateway's - logs and analytics key off the real user, not whichever - app process happens to hold the API token. Pick the JSON - key names that match your existing log filters; leave a - field empty to omit that key from the JSON. The proxy - strips any client-supplied value first, so an app - can't spoof identity. + logs and analytics key off the real user, not whichever app + process happens to hold the API token. Pick the JSON key + names that match your existing log filters; leave a field + empty to omit that key from the JSON. The proxy strips any + client-supplied value first, so an app can't spoof + identity. x-portkey-metadata {" "} - header with a JSON object so Portkey's analytics - and budgets key off the real caller. The proxy strips - any client-supplied value first, so an app can't - spoof identity. Per Portkey's 128-character cap - each value is truncated when needed. The mapping is - fixed in this release. + header with a JSON object so Portkey's analytics and + budgets key off the real caller. The proxy strips any + client-supplied value first, so an app can't spoof + identity. Per Portkey's 128-character cap each value is + truncated when needed. The mapping is fixed in this release. @@ -1187,9 +1308,9 @@ export default function AIProviderModal({ > group_by=tag - ). Header names are fixed by Vercel's API contract - — renaming would silently disable attribution. The - proxy strips any client-supplied value first. + ). Header names are fixed by Vercel's API contract — + renaming would silently disable attribution. The proxy + strips any client-supplied value first. @@ -1210,11 +1331,11 @@ export default function AIProviderModal({ Caveats: Vercel caps tags at 10 per request - (each 1–64 chars) and the user value at 256 chars. Members - of more than 10 groups will see Vercel reject the request - with HTTP 400 — re-scope group memberships if you hit it. - Vercel charges $0.075 per 1,000 unique user/tag values - written; budget accordingly for high-cardinality use cases. + (each 1–64 chars) and the user value at 256 chars. Members of + more than 10 groups will see Vercel reject the request with + HTTP 400 — re-scope group memberships if you hit it. Vercel + charges $0.075 per 1,000 unique user/tag values written; + budget accordingly for high-cardinality use cases. @@ -1235,10 +1356,10 @@ export default function AIProviderModal({ > user {" "} - field — that's the OpenAI-standard field - OpenRouter consults for per-user analytics. The proxy - overwrites any client-supplied value first, so an app - can't spoof identity. + field — that's the OpenAI-standard field OpenRouter + consults for per-user analytics. The proxy overwrites any + client-supplied value first, so an app can't spoof + identity. @@ -1256,16 +1377,17 @@ export default function AIProviderModal({ No groups dimension. OpenRouter does not document a per-request tag, label, or team field — only - per-user identity. NetBird's group memberships are - not propagated to OpenRouter; if you need per-group - attribution, query NetBird's own access log instead - of OpenRouter's analytics. + per-user identity. NetBird's group memberships are not + propagated to OpenRouter; if you need per-group attribution, + query NetBird's own access log instead of + OpenRouter's analytics. - App branding (HTTP-Referer + X-OpenRouter-Title) - is set per-provider on the Provider tab, not per-request. - Operators who fill those in get their app surfaced on - OpenRouter's public rankings and per-app analytics. + App branding (HTTP-Referer + + X-OpenRouter-Title) is set per-provider on the Provider tab, + not per-request. Operators who fill those in get their app + surfaced on OpenRouter's public rankings and per-app + analytics. @@ -1278,18 +1400,81 @@ export default function AIProviderModal({ Models exposed through this endpoint, with the per-1k input/output prices used for cost tracking. Empty = all - catalog models allowed at catalog prices. Cache rates - left empty fall back to NetBird's defaults for the - model; 0 bills cached tokens at the input rate. + catalog models allowed at catalog prices. Cache rates left + empty fall back to NetBird's defaults for the model; 0 + bills cached tokens at the input rate. +
+ + {!canDiscoverModels && ( + + Enter the endpoint URL and API key first. + + )} + {discovered.notSupported && ( + + This provider has no model listing endpoint — the catalog + list is used instead. + + )} + {discovered.error && ( + + {discovered.error} + + )} +
+ + {!discovered.isLoading && + !discovered.error && + discovered.models.length > 0 && ( + + {discovered.models.length} models loaded. Use the{" "} + Add More button to search and pick models. + + )} + + {unpricedModelIds.size > 0 && ( + // A callout rather than a line of help text: this is the one + // thing on the tab that costs money to miss, and it sat in the + // same grey run of prose as everything else. + + } + > + {unpricedModelIds.size === 1 + ? "The model below has" + : `The ${unpricedModelIds.size} models below have`}{" "} + no cost set. Usage is tracked at $0 and won't count + toward budget limits. + + )} + {models.map((row, idx) => ( { const fromCatalog = catalogModelOptions.find( (m) => m.id === id, @@ -1403,10 +1588,7 @@ export default function AIProviderModal({ )} {tab === "mappings" && ( <> -