Pass 1 of the config instructions

This commit is contained in:
Owen
2026-08-18 13:47:08 -04:00
parent 6c6e5c0fdf
commit 2a3c00045f
9 changed files with 667 additions and 7 deletions
+10
View File
@@ -1780,6 +1780,16 @@
"myVirtualApiKeysUnnamed": "Unnamed key", "myVirtualApiKeysUnnamed": "Unnamed key",
"myVirtualApiKeysRevealSecret": "Reveal Secret", "myVirtualApiKeysRevealSecret": "Reveal Secret",
"myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources", "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", "aiProvidersTitle": "AI Providers",
"aiProvidersDescription": "Connect model providers for AI workloads in this organization", "aiProvidersDescription": "Connect model providers for AI workloads in this organization",
"aiProvidersBannerTitle": "Connect Model Providers", "aiProvidersBannerTitle": "Connect Model Providers",
@@ -27,6 +27,7 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link";
import { useActionState, useMemo } from "react"; import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -70,6 +71,16 @@ export default function PrivateResourceGeneralPage() {
<SettingsSectionDescription> <SettingsSectionDescription>
{t("privateResourceGeneralDescription")} {t("privateResourceGeneralDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
{siteResource.mode === "inference" ? (
<p className="text-sm pt-1">
<Link
href={`/${siteResource.orgId}?query=${encodeURIComponent(siteResource.name)}`}
className="text-primary hover:underline"
>
{t("resourceGeneralAiClientConfigLink")}
</Link>
</p>
) : null}
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
@@ -256,6 +256,16 @@ export default function GeneralForm() {
<SettingsSectionDescription> <SettingsSectionDescription>
{t("resourceGeneralDescription")} {t("resourceGeneralDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
{resource.mode === "inference" ? (
<p className="text-sm pt-1">
<Link
href={`/${resource.orgId}?query=${encodeURIComponent(resource.name)}`}
className="text-primary hover:underline"
>
{t("resourceGeneralAiClientConfigLink")}
</Link>
</p>
) : null}
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
+18
View File
@@ -5,6 +5,7 @@ import moment from "moment";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell, SettingsFormCell,
@@ -166,6 +167,14 @@ export default function UserVirtualApiKeys({
}: UserVirtualApiKeysProps) { }: UserVirtualApiKeysProps) {
const t = useTranslations(); const t = useTranslations();
const resourceName = initialData.resourceName; const resourceName = initialData.resourceName;
const { getCopyText: getKeyCopyText } = useMyVirtualApiKeySecret(
orgId,
initialData.userKey.virtualApiKeyId
);
const keyPreview = formatVirtualApiKeyPreview(
initialData.userKey.virtualApiKeyId,
initialData.userKey.lastChars
);
return ( return (
<> <>
@@ -177,6 +186,15 @@ export default function UserVirtualApiKeys({
resourceName={resourceName} resourceName={resourceName}
/> />
<AiClientConfigSection
endpoint={t("aiClientConfigEndpointPlaceholder")}
auth={{
mode: "keyed",
keyDisplay: keyPreview,
getKeyText: getKeyCopyText
}}
/>
{initialData.manualKeys.length > 0 ? ( {initialData.manualKeys.length > 0 ? (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@@ -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<AiClientPresetId>(
guide.presets[0]?.id ?? "default"
);
const preset =
guide.presets.find((p) => p.id === presetId) ?? guide.presets[0];
const manualContent = (
<div className="space-y-4">
{guide.presets.length > 1 ? (
<Select
value={presetId}
onValueChange={(value) =>
setPresetId(value as AiClientPresetId)
}
>
<SelectTrigger size="sm" className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{guide.presets.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<div className="grid gap-4 @lg:grid-cols-2">
{preset?.blocks.map((block) => (
<AiConfigCodeBlock key={block.id} block={block} />
))}
</div>
</div>
);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="rounded-md border bg-card"
>
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{guide.name}</p>
<p className="text-xs text-muted-foreground truncate">
{description}
</p>
</div>
<ChevronDown
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180"
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="border-t px-4 py-4">
{guide.cli ? (
<Tabs defaultValue="cli">
<TabsList>
<TabsTrigger value="cli">
{t("aiClientConfigTabCli")}
</TabsTrigger>
<TabsTrigger value="manual">
{t("aiClientConfigTabManual")}
</TabsTrigger>
</TabsList>
<TabsContent
value="cli"
className="grid gap-4 @lg:grid-cols-2 mt-4"
>
<AiConfigCodeBlock block={guide.cli.configure} />
<AiConfigCodeBlock block={guide.cli.run} />
{guide.cli.configureWithKey ? (
<AiConfigCodeBlock
block={guide.cli.configureWithKey}
/>
) : null}
{guide.cli.runWithKey ? (
<AiConfigCodeBlock
block={guide.cli.runWithKey}
/>
) : null}
</TabsContent>
<TabsContent value="manual" className="mt-4">
{manualContent}
</TabsContent>
</Tabs>
) : (
manualContent
)}
</CollapsibleContent>
</Collapsible>
);
}
@@ -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<string, string> = {
claude: t("aiClientConfigDescriptionClaude"),
codex: t("aiClientConfigDescriptionCodex"),
opencode: t("aiClientConfigDescriptionOpencode"),
cursor: t("aiClientConfigDescriptionCursor")
};
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiClientConfigTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiClientConfigDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div
className={cn("@container space-y-3", className)}
>
{guides.map((guide, index) => (
<AiClientConfigCard
key={guide.id}
guide={guide}
description={descriptions[guide.id]}
icon={icons[guide.id]}
defaultOpen={index === 0}
/>
))}
</div>
</SettingsSectionBody>
</SettingsSection>
);
}
@@ -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 (
<div className="space-y-1.5">
<p className="font-mono text-xs text-muted-foreground">
{block.label}
</p>
<div
className={
block.kind === "steps"
? "[&_pre]:text-sm"
: "[&_pre]:text-xs [&_code]:font-mono"
}
>
<CopyTextBox
text={block.displayText}
getCopyText={block.getCopyText}
wrapText={block.kind === "steps"}
/>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
"use client"; "use client";
import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { import {
InfoSection, InfoSection,
@@ -27,6 +28,7 @@ import {
} from "@app/components/SidePanel"; } from "@app/components/SidePanel";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
import { import {
derivePublicAuthState, derivePublicAuthState,
formatPublicResourceType formatPublicResourceType
@@ -34,6 +36,7 @@ import {
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref"; import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
import { launcherQueries } from "@app/lib/queries"; import { launcherQueries } from "@app/lib/queries";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import type { LauncherResource } from "@server/routers/launcher/types"; import type { LauncherResource } from "@server/routers/launcher/types";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo"; import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetResourceResponse } from "@server/routers/resource/getResource"; import type { GetResourceResponse } from "@server/routers/resource/getResource";
@@ -249,6 +252,15 @@ function PublicResourceDetails({
const authState = derivePublicAuthState(resource.mode, authInfo); const authState = derivePublicAuthState(resource.mode, authInfo);
const infoSectionCount = 2 + (showAuthBadge ? 1 : 0) + (showHealth ? 1 : 0); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsSection> <SettingsSection>
@@ -333,6 +345,19 @@ function PublicResourceDetails({
orgId={orgId} orgId={orgId}
resourceGuid={resource.resourceGuid} resourceGuid={resource.resourceGuid}
/> />
{aiKeysData ? (
<AiClientConfigSection
endpoint={launcherResource.accessUrl ?? ""}
auth={{
mode: "keyed",
keyDisplay: formatVirtualApiKeyPreview(
aiKeysData.userKey.virtualApiKeyId,
aiKeysData.userKey.lastChars
),
getKeyText: getAiKeyCopyText
}}
/>
) : null}
</> </>
) : null} ) : null}
</div> </div>
@@ -397,6 +422,7 @@ function PrivateResourceDetails({
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
{isInference ? ( {isInference ? (
<>
<LauncherInferenceModelsSection <LauncherInferenceModelsSection
orgId={orgId} orgId={orgId}
params={{ params={{
@@ -404,6 +430,11 @@ function PrivateResourceDetails({
siteResourceId: resource.siteResourceId siteResourceId: resource.siteResourceId
}} }}
/> />
<AiClientConfigSection
endpoint={launcherResource.accessUrl ?? ""}
auth={{ mode: "keyless" }}
/>
</>
) : null} ) : null}
</div> </div>
); );
+338
View File
@@ -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<string> }
| { mode: "keyless" };
export type AiConfigBlock = {
id: string;
label: string;
kind?: "code" | "steps";
displayText: string;
getCopyText?: () => Promise<string>;
};
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<string>;
} {
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": "<your-gcp-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)
];
}