From 2a3c00045f786d85bce2d2d0785bc3ceb86fe799 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 18 Aug 2026 13:47:08 -0400 Subject: [PATCH] Pass 1 of the config instructions --- messages/en-US.json | 10 + .../private/[niceId]/general/page.tsx | 11 + .../public/[niceId]/general/page.tsx | 10 + src/components/UserVirtualApiKeys.tsx | 18 + .../ai-client-config/AiClientConfigCard.tsx | 138 +++++++ .../AiClientConfigSection.tsx | 77 ++++ .../ai-client-config/AiConfigCodeBlock.tsx | 27 ++ .../LauncherResourcePanel.tsx | 45 ++- src/lib/aiClientConfig.ts | 338 ++++++++++++++++++ 9 files changed, 667 insertions(+), 7 deletions(-) create mode 100644 src/components/ai-client-config/AiClientConfigCard.tsx create mode 100644 src/components/ai-client-config/AiClientConfigSection.tsx create mode 100644 src/components/ai-client-config/AiConfigCodeBlock.tsx create mode 100644 src/lib/aiClientConfig.ts diff --git a/messages/en-US.json b/messages/en-US.json index 27be1ffe3..a4dec1b17 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1780,6 +1780,16 @@ "myVirtualApiKeysUnnamed": "Unnamed key", "myVirtualApiKeysRevealSecret": "Reveal Secret", "myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources", + "aiClientConfigTitle": "Configure Coding Agents", + "aiClientConfigDescription": "Copy configuration for popular coding agents, or let the Pangolin CLI set it up for you automatically.", + "aiClientConfigDescriptionClaude": "Anthropic's agentic coding tool for the terminal.", + "aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.", + "aiClientConfigDescriptionOpencode": "Open source terminal coding agent.", + "aiClientConfigDescriptionCursor": "AI code editor built on VS Code.", + "aiClientConfigTabCli": "Automatic (CLI)", + "aiClientConfigTabManual": "Manual Configuration", + "aiClientConfigEndpointPlaceholder": "https://example.resource.url.com", + "resourceGeneralAiClientConfigLink": "See how to configure access to this resource in common clients like Claude Code and OpenCode", "aiProvidersTitle": "AI Providers", "aiProvidersDescription": "Connect model providers for AI workloads in this organization", "aiProvidersBannerTitle": "Connect Model Providers", diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx index 8dbdb3809..37c69ec1a 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -27,6 +27,7 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { useActionState, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -70,6 +71,16 @@ export default function PrivateResourceGeneralPage() { {t("privateResourceGeneralDescription")} + {siteResource.mode === "inference" ? ( +

+ + {t("resourceGeneralAiClientConfigLink")} + +

+ ) : null} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx index e1f087671..d4d32b044 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx @@ -256,6 +256,16 @@ export default function GeneralForm() { {t("resourceGeneralDescription")} + {resource.mode === "inference" ? ( +

+ + {t("resourceGeneralAiClientConfigLink")} + +

+ ) : null} diff --git a/src/components/UserVirtualApiKeys.tsx b/src/components/UserVirtualApiKeys.tsx index 1ab2ae92d..9b2c9ba7b 100644 --- a/src/components/UserVirtualApiKeys.tsx +++ b/src/components/UserVirtualApiKeys.tsx @@ -5,6 +5,7 @@ import moment from "moment"; import { Button } from "@app/components/ui/button"; import CopyTextBox from "@app/components/CopyTextBox"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection"; import { SettingsContainer, SettingsFormCell, @@ -166,6 +167,14 @@ export default function UserVirtualApiKeys({ }: UserVirtualApiKeysProps) { const t = useTranslations(); const resourceName = initialData.resourceName; + const { getCopyText: getKeyCopyText } = useMyVirtualApiKeySecret( + orgId, + initialData.userKey.virtualApiKeyId + ); + const keyPreview = formatVirtualApiKeyPreview( + initialData.userKey.virtualApiKeyId, + initialData.userKey.lastChars + ); return ( <> @@ -177,6 +186,15 @@ export default function UserVirtualApiKeys({ resourceName={resourceName} /> + + {initialData.manualKeys.length > 0 ? ( diff --git a/src/components/ai-client-config/AiClientConfigCard.tsx b/src/components/ai-client-config/AiClientConfigCard.tsx new file mode 100644 index 000000000..0bda5f40b --- /dev/null +++ b/src/components/ai-client-config/AiClientConfigCard.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { AiConfigCodeBlock } from "@app/components/ai-client-config/AiConfigCodeBlock"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@app/components/ui/collapsible"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from "@app/components/ui/tabs"; +import type { AiClientGuide, AiClientPresetId } from "@app/lib/aiClientConfig"; +import { cn } from "@app/lib/cn"; +import { ChevronDown, type LucideIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +type AiClientConfigCardProps = { + guide: AiClientGuide; + description: string; + icon: LucideIcon; + defaultOpen?: boolean; +}; + +export function AiClientConfigCard({ + guide, + description, + icon: Icon, + defaultOpen = false +}: AiClientConfigCardProps) { + const t = useTranslations(); + const [open, setOpen] = useState(defaultOpen); + const [presetId, setPresetId] = useState( + guide.presets[0]?.id ?? "default" + ); + + const preset = + guide.presets.find((p) => p.id === presetId) ?? guide.presets[0]; + + const manualContent = ( +
+ {guide.presets.length > 1 ? ( + + ) : null} +
+ {preset?.blocks.map((block) => ( + + ))} +
+
+ ); + + return ( + + + +
+

{guide.name}

+

+ {description} +

+
+ +
+ + {guide.cli ? ( + + + + {t("aiClientConfigTabCli")} + + + {t("aiClientConfigTabManual")} + + + + + + {guide.cli.configureWithKey ? ( + + ) : null} + {guide.cli.runWithKey ? ( + + ) : null} + + + {manualContent} + + + ) : ( + manualContent + )} + +
+ ); +} diff --git a/src/components/ai-client-config/AiClientConfigSection.tsx b/src/components/ai-client-config/AiClientConfigSection.tsx new file mode 100644 index 000000000..4b208b885 --- /dev/null +++ b/src/components/ai-client-config/AiClientConfigSection.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { AiClientConfigCard } from "@app/components/ai-client-config/AiClientConfigCard"; +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import type { AiClientAuth } from "@app/lib/aiClientConfig"; +import { buildAiClientGuides } from "@app/lib/aiClientConfig"; +import { cn } from "@app/lib/cn"; +import { MousePointerClick, Sparkles, SquareTerminal, TerminalSquare } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo } from "react"; + +type AiClientConfigSectionProps = { + endpoint: string; + auth: AiClientAuth; + className?: string; +}; + +export function AiClientConfigSection({ + endpoint, + auth, + className +}: AiClientConfigSectionProps) { + const t = useTranslations(); + + const guides = useMemo( + () => buildAiClientGuides(endpoint, auth), + [endpoint, auth] + ); + + const icons = { + claude: Sparkles, + codex: TerminalSquare, + opencode: SquareTerminal, + cursor: MousePointerClick + } as const; + + const descriptions: Record = { + claude: t("aiClientConfigDescriptionClaude"), + codex: t("aiClientConfigDescriptionCodex"), + opencode: t("aiClientConfigDescriptionOpencode"), + cursor: t("aiClientConfigDescriptionCursor") + }; + + return ( + + + + {t("aiClientConfigTitle")} + + + {t("aiClientConfigDescription")} + + + +
+ {guides.map((guide, index) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/ai-client-config/AiConfigCodeBlock.tsx b/src/components/ai-client-config/AiConfigCodeBlock.tsx new file mode 100644 index 000000000..db2d2658d --- /dev/null +++ b/src/components/ai-client-config/AiConfigCodeBlock.tsx @@ -0,0 +1,27 @@ +"use client"; + +import CopyTextBox from "@app/components/CopyTextBox"; +import type { AiConfigBlock } from "@app/lib/aiClientConfig"; + +export function AiConfigCodeBlock({ block }: { block: AiConfigBlock }) { + return ( +
+

+ {block.label} +

+
+ +
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx index 999d1bc03..e8d7398d6 100644 --- a/src/components/resource-launcher/LauncherResourcePanel.tsx +++ b/src/components/resource-launcher/LauncherResourcePanel.tsx @@ -1,5 +1,6 @@ "use client"; +import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { InfoSection, @@ -27,6 +28,7 @@ import { } from "@app/components/SidePanel"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; +import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret"; import { derivePublicAuthState, formatPublicResourceType @@ -34,6 +36,7 @@ import { import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref"; import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; import { launcherQueries } from "@app/lib/queries"; +import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat"; import type { LauncherResource } from "@server/routers/launcher/types"; import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo"; import type { GetResourceResponse } from "@server/routers/resource/getResource"; @@ -249,6 +252,15 @@ function PublicResourceDetails({ const authState = derivePublicAuthState(resource.mode, authInfo); const infoSectionCount = 2 + (showAuthBadge ? 1 : 0) + (showHealth ? 1 : 0); + const { data: aiKeysData } = useQuery({ + ...launcherQueries.myVirtualApiKeys(orgId, resource.resourceGuid), + enabled: isInference + }); + const { getCopyText: getAiKeyCopyText } = useMyVirtualApiKeySecret( + orgId, + aiKeysData?.userKey.virtualApiKeyId ?? "" + ); + return (
@@ -333,6 +345,19 @@ function PublicResourceDetails({ orgId={orgId} resourceGuid={resource.resourceGuid} /> + {aiKeysData ? ( + + ) : null} ) : null}
@@ -397,13 +422,19 @@ function PrivateResourceDetails({
{isInference ? ( - + <> + + + ) : null} ); diff --git a/src/lib/aiClientConfig.ts b/src/lib/aiClientConfig.ts new file mode 100644 index 000000000..1d7ade034 --- /dev/null +++ b/src/lib/aiClientConfig.ts @@ -0,0 +1,338 @@ +export const AI_CLIENT_IDS = ["claude", "codex", "opencode", "cursor"] as const; +export type AiClientId = (typeof AI_CLIENT_IDS)[number]; + +export type AiClientAuth = + | { mode: "keyed"; keyDisplay: string; getKeyText: () => Promise } + | { mode: "keyless" }; + +export type AiConfigBlock = { + id: string; + label: string; + kind?: "code" | "steps"; + displayText: string; + getCopyText?: () => Promise; +}; + +export type AiClientPresetId = "default" | "bedrock" | "vertex" | "kimi"; + +export type AiConfigPreset = { + id: AiClientPresetId; + label: string; + blocks: AiConfigBlock[]; +}; + +export type AiCliCommands = { + configure: AiConfigBlock; + configureWithKey?: AiConfigBlock; + run: AiConfigBlock; + runWithKey?: AiConfigBlock; +}; + +export type AiClientGuide = { + id: AiClientId; + name: string; + cli: AiCliCommands | null; + presets: AiConfigPreset[]; +}; + +function authValue(auth: AiClientAuth): { + display: string; + getCopyText?: () => Promise; +} { + if (auth.mode === "keyed") { + return { display: auth.keyDisplay, getCopyText: auth.getKeyText }; + } + return { display: "-" }; +} + +function block( + id: string, + label: string, + build: (keyValue: string) => string, + auth: AiClientAuth, + kind: "code" | "steps" = "code" +): AiConfigBlock { + const { display, getCopyText } = authValue(auth); + return { + id, + label, + kind, + displayText: build(display), + getCopyText: getCopyText ? async () => build(await getCopyText()) : undefined + }; +} + +function buildCli(clientArg: "claude" | "codex", auth: AiClientAuth): AiCliCommands { + const configure: AiConfigBlock = { + id: `cli-configure-${clientArg}`, + label: "Configure", + displayText: `pangolin configure ${clientArg}` + }; + const run: AiConfigBlock = { + id: `cli-run-${clientArg}`, + label: "Run", + displayText: `pangolin run ${clientArg}` + }; + + if (auth.mode !== "keyed") { + return { configure, run }; + } + + return { + configure, + run, + configureWithKey: { + id: `cli-configure-key-${clientArg}`, + label: "Configure with an API key", + displayText: `pangolin configure ${clientArg} ${auth.keyDisplay}`, + getCopyText: async () => + `pangolin configure ${clientArg} ${await auth.getKeyText()}` + }, + runWithKey: { + id: `cli-run-key-${clientArg}`, + label: "Run with an API key", + displayText: `pangolin run ${clientArg} ${auth.keyDisplay}`, + getCopyText: async () => + `pangolin run ${clientArg} ${await auth.getKeyText()}` + } + }; +} + +function buildClaudeGuide(endpoint: string, auth: AiClientAuth): AiClientGuide { + const defaultSettings = block( + "claude-default-settings", + "~/.claude/settings.json", + (key) => + [ + "{", + ` "apiKeyHelper": "echo '${key}'",`, + ' "env": {', + ` "ANTHROPIC_BASE_URL": "${endpoint}"`, + " }", + "}" + ].join("\n"), + auth + ); + + const defaultShell = block( + "claude-default-shell", + "Shell", + (key) => + [ + `export ANTHROPIC_BASE_URL=${endpoint}`, + `export ANTHROPIC_API_KEY=${auth.mode === "keyed" ? key : "none"}`, + "claude" + ].join("\n"), + auth + ); + + const bedrockSettings = block( + "claude-bedrock-settings", + "~/.claude/settings.json", + () => + [ + "{", + ' "env": {', + ' "ANTHROPIC_MODEL": "claude-sonnet-4-6",', + ` "ANTHROPIC_BEDROCK_BASE_URL": "${endpoint}/bedrock",`, + ' "CLAUDE_CODE_USE_BEDROCK": "1",', + ' "CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1"', + " }", + "}" + ].join("\n"), + auth + ); + + const vertexSettings = block( + "claude-vertex-settings", + "~/.claude/settings.json", + () => + [ + "{", + ' "env": {', + ' "CLOUD_ML_REGION": "global",', + ' "ANTHROPIC_VERTEX_PROJECT_ID": "",', + ' "CLAUDE_CODE_USE_VERTEX": "1",', + ' "CLAUDE_CODE_SKIP_VERTEX_AUTH": "1",', + ` "ANTHROPIC_VERTEX_BASE_URL": "${endpoint}/v1"`, + " }", + "}" + ].join("\n"), + auth + ); + + const kimiSettings = block( + "claude-kimi-settings", + "~/.claude/settings.json", + (key) => + [ + "{", + ` "apiKeyHelper": "echo '${key}'",`, + ' "env": {', + ` "ANTHROPIC_BASE_URL": "${endpoint}/anthropic",`, + ' "ANTHROPIC_MODEL": "kimi-k2",', + ' "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k2",', + ' "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2",', + ' "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-k2",', + ' "CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k2",', + ' "ENABLE_TOOL_SEARCH": "false"', + " }", + "}" + ].join("\n"), + auth + ); + + return { + id: "claude", + name: "Claude Code", + cli: buildCli("claude", auth), + presets: [ + { + id: "default", + label: "Default (Anthropic)", + blocks: [defaultSettings, defaultShell] + }, + { + id: "bedrock", + label: "Amazon Bedrock", + blocks: [bedrockSettings] + }, + { + id: "vertex", + label: "Google Vertex AI", + blocks: [vertexSettings] + }, + { + id: "kimi", + label: "Kimi K2 (Moonshot AI)", + blocks: [kimiSettings] + } + ] + }; +} + +function buildCodexGuide(endpoint: string, auth: AiClientAuth): AiClientGuide { + const settings = block( + "codex-settings", + "~/.codex/config.toml", + () => + [ + 'model_provider = "pangolin"', + "", + "[model_providers.pangolin]", + 'name = "Pangolin AI Gateway"', + `base_url = "${endpoint}/v1"`, + 'wire_api = "responses"', + ...(auth.mode === "keyed" ? ['env_key = "PANGOLIN_API_KEY"'] : []) + ].join("\n"), + auth + ); + + const shell = + auth.mode === "keyed" + ? block( + "codex-shell", + "Shell", + (key) => `export PANGOLIN_API_KEY=${key}`, + auth + ) + : null; + + return { + id: "codex", + name: "Codex", + cli: buildCli("codex", auth), + presets: [ + { + id: "default", + label: "Default", + blocks: shell ? [settings, shell] : [settings] + } + ] + }; +} + +function buildOpencodeGuide(endpoint: string, auth: AiClientAuth): AiClientGuide { + const config = block( + "opencode-config", + "opencode.json", + () => + [ + "{", + ' "$schema": "https://opencode.ai/config.json",', + ' "provider": {', + ' "anthropic": {', + ' "options": {', + ` "baseURL": "${endpoint}/v1"`, + " }", + " }", + " }", + "}" + ].join("\n"), + auth + ); + + const authFile = block( + "opencode-auth", + "auth.json", + (key) => + ["{", ' "anthropic": {', ' "type": "api",', ` "key": "${key}"`, " }", "}"].join( + "\n" + ), + auth + ); + + return { + id: "opencode", + name: "OpenCode", + cli: null, + presets: [ + { + id: "default", + label: "Default", + blocks: [config, authFile] + } + ] + }; +} + +function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide { + const steps = block( + "cursor-steps", + "Cursor Settings", + (key) => + [ + "1. Open Cursor Settings -> Models.", + '2. Enable "Override OpenAI Base URL".', + `3. Set the base URL to: ${endpoint}/v1`, + auth.mode === "keyed" + ? `4. Paste your API key into the OpenAI API Key field: ${key}` + : '4. Leave the OpenAI API Key field set to a placeholder (e.g. "-"). Pangolin authenticates the request over your Newt/Olm connection automatically.', + "5. Add a custom model matching the model your Pangolin AI Gateway serves (e.g. claude-sonnet-4-6)." + ].join("\n"), + auth, + "steps" + ); + + return { + id: "cursor", + name: "Cursor", + cli: null, + presets: [ + { + id: "default", + label: "Default", + blocks: [steps] + } + ] + }; +} + +export function buildAiClientGuides(endpoint: string, auth: AiClientAuth): AiClientGuide[] { + return [ + buildClaudeGuide(endpoint, auth), + buildCodexGuide(endpoint, auth), + buildOpencodeGuide(endpoint, auth), + buildCursorGuide(endpoint, auth) + ]; +}