diff --git a/messages/en-US.json b/messages/en-US.json
index e0d18f68d..dff0cdcfd 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1786,12 +1786,16 @@
"aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.",
"aiClientConfigDescriptionOpencode": "Open source terminal coding agent.",
"aiClientConfigDescriptionCursor": "AI code editor built on VS Code.",
+ "aiClientConfigSetup": "Setup",
"aiClientConfigTabCli": "Automatic (CLI)",
"aiClientConfigTabManual": "Manual Configuration",
+ "aiClientConfigPreset": "Configuration",
+ "aiClientConfigStep": "Step {number}",
"aiClientConfigEndpointPlaceholder": "https://example.resource.url.com",
+ "aiClientConfigPlaceholderWarning": "This snippet includes example values, such as a model name or resource URL. Replace them with your own before using it.",
"aiClientConfigRevealError": "Could not load your API key.",
"aiClientConfigRevealRetry": "Try again",
- "resourceGeneralAiClientConfigLink": "See how to configure access to this resource in common clients like Claude Code and OpenCode",
+ "resourceGeneralAiClientConfigDescription": "This resource can be used from common clients like Claude Code and OpenCode. Learn more ",
"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 c56644a9e..67b0a3b83 100644
--- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
+++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
@@ -28,6 +28,7 @@ import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import Link from "next/link";
+import { ExternalLink } from "lucide-react";
import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -70,17 +71,26 @@ export default function PrivateResourceGeneralPage() {
{t("privateResourceGeneralDescription")}
+ {siteResource.mode === "inference" ? (
+ <>
+ {" "}
+ {t.rich(
+ "resourceGeneralAiClientConfigDescription",
+ {
+ configLink: (chunks) => (
+
+ {chunks}
+
+
+ )
+ }
+ )}
+ >
+ ) : null}
- {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 af47de7bf..c92c5dcd4 100644
--- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx
+++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx
@@ -50,6 +50,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
+import { ExternalLink } from "lucide-react";
import { build } from "@server/build";
import { TierFeature } from "@server/lib/billing/tierMatrix";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
@@ -255,17 +256,26 @@ export default function GeneralForm() {
{t("resourceGeneralDescription")}
+ {resource.mode === "inference" ? (
+ <>
+ {" "}
+ {t.rich(
+ "resourceGeneralAiClientConfigDescription",
+ {
+ configLink: (chunks) => (
+
+ {chunks}
+
+
+ )
+ }
+ )}
+ >
+ ) : null}
- {resource.mode === "inference" ? (
-
-
- {t("resourceGeneralAiClientConfigLink")}
-
-
- ) : null}
diff --git a/src/components/ai-client-config/AiClientConfigCard.tsx b/src/components/ai-client-config/AiClientConfigCard.tsx
index 8b6747896..d4c9c44e6 100644
--- a/src/components/ai-client-config/AiClientConfigCard.tsx
+++ b/src/components/ai-client-config/AiClientConfigCard.tsx
@@ -1,25 +1,13 @@
"use client";
-import { AiConfigCodeBlock } from "@app/components/ai-client-config/AiConfigCodeBlock";
+import { AiConfigBlocks } from "@app/components/ai-client-config/AiConfigBlocks";
import { Button } from "@app/components/ui/button";
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 { OptionSelect } from "@app/components/OptionSelect";
import type {
AiClientAuthInput,
AiClientId,
@@ -43,6 +31,8 @@ type AiClientConfigCardProps = {
resourceNiceId?: string;
};
+type SetupMode = "cli" | "manual";
+
export function AiClientConfigCard({
clientId,
name,
@@ -57,6 +47,7 @@ export function AiClientConfigCard({
const [resolvedIconSrc, setResolvedIconSrc] = useState(iconSrc.light);
const [open, setOpen] = useState(false);
const [presetId, setPresetId] = useState("default");
+ const [setupMode, setSetupMode] = useState("cli");
const [revealedKey, setRevealedKey] = useState(null);
const [revealing, setRevealing] = useState(false);
const [revealError, setRevealError] = useState(false);
@@ -120,29 +111,24 @@ export function AiClientConfigCard({
const manualContent = guide ? (
{guide.presets.length > 1 ? (
-
- setPresetId(value as AiClientPresetId)
- }
- >
-
-
-
-
- {guide.presets.map((p) => (
-
- {p.label}
-
- ))}
-
-
+
+ label={t("aiClientConfigPreset")}
+ options={guide.presets.map((p) => ({
+ value: p.id,
+ label: p.label
+ }))}
+ value={preset?.id ?? presetId}
+ onChange={setPresetId}
+ cols={2}
+ />
+ ) : null}
+ {preset ? (
+
) : null}
-
- {preset?.blocks.map((block) => (
-
- ))}
-
) : null;
@@ -191,35 +177,32 @@ export function AiClientConfigCard({
) : null}
{guide ? (
guide.cli ? (
-
-
-
- {t("aiClientConfigTabCli")}
-
-
- {t("aiClientConfigTabManual")}
-
-
-
-
+
+ label={t("aiClientConfigSetup")}
+ options={[
+ {
+ value: "cli",
+ label: t("aiClientConfigTabCli")
+ },
+ {
+ value: "manual",
+ label: t("aiClientConfigTabManual")
+ }
+ ]}
+ value={setupMode}
+ onChange={setSetupMode}
+ cols={2}
+ />
+ {setupMode === "cli" ? (
+
- {guide.cli.configureWithKey ? (
-
- ) : null}
-
-
- {manualContent}
-
-
+ ) : (
+ manualContent
+ )}
+
) : (
manualContent
)
diff --git a/src/components/ai-client-config/AiConfigBlocks.tsx b/src/components/ai-client-config/AiConfigBlocks.tsx
new file mode 100644
index 000000000..1d919d031
--- /dev/null
+++ b/src/components/ai-client-config/AiConfigBlocks.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { AiConfigCodeBlock } from "@app/components/ai-client-config/AiConfigCodeBlock";
+import {
+ OptionSelect,
+ type OptionSelectOption
+} from "@app/components/OptionSelect";
+import type { AiConfigBlock, AiConfigRelation } from "@app/lib/aiClientConfig";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useState } from "react";
+
+type AiConfigBlocksProps = {
+ blocks: AiConfigBlock[];
+ relation: AiConfigRelation;
+};
+
+export function AiConfigBlocks({ blocks, relation }: AiConfigBlocksProps) {
+ const t = useTranslations();
+ const showPicker = relation === "options" && blocks.length > 1;
+ const [selectedId, setSelectedId] = useState(blocks[0]?.id ?? "");
+
+ const methodOptions: OptionSelectOption[] = useMemo(
+ () =>
+ blocks.map((block) => ({
+ value: block.id,
+ label: block.label
+ })),
+ [blocks]
+ );
+
+ useEffect(() => {
+ if (!blocks.some((block) => block.id === selectedId)) {
+ setSelectedId(blocks[0]?.id ?? "");
+ }
+ }, [blocks, selectedId]);
+
+ if (blocks.length === 0) {
+ return null;
+ }
+
+ if (showPicker) {
+ const selected =
+ blocks.find((block) => block.id === selectedId) ?? blocks[0];
+
+ return (
+
+ );
+ }
+
+ const numberSteps = relation === "steps" && blocks.length > 1;
+
+ return (
+
+ {blocks.map((block, index) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/ai-client-config/AiConfigCodeBlock.tsx b/src/components/ai-client-config/AiConfigCodeBlock.tsx
index 95c088160..381071d5a 100644
--- a/src/components/ai-client-config/AiConfigCodeBlock.tsx
+++ b/src/components/ai-client-config/AiConfigCodeBlock.tsx
@@ -1,23 +1,53 @@
"use client";
import CopyTextBox from "@app/components/CopyTextBox";
-import type { AiConfigBlock } from "@app/lib/aiClientConfig";
+import { Alert, AlertDescription } from "@app/components/ui/alert";
+import {
+ aiConfigBlockHasPlaceholders,
+ type AiConfigBlock
+} from "@app/lib/aiClientConfig";
import { cn } from "@app/lib/cn";
+import { AlertCircle } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+type AiConfigCodeBlockProps = {
+ block: AiConfigBlock;
+ step?: number;
+ hideLabel?: boolean;
+};
+
+export function AiConfigCodeBlock({
+ block,
+ step,
+ hideLabel = false
+}: AiConfigCodeBlockProps) {
+ const t = useTranslations();
+ const label =
+ step != null
+ ? `${t("aiClientConfigStep", { number: step })}: ${block.label}`
+ : block.label;
-export function AiConfigCodeBlock({ block }: { block: AiConfigBlock }) {
return (
-
- {block.label}
-
+ {!hideLabel ? (
+
+ {label}
+
+ ) : null}
+ {aiConfigBlockHasPlaceholders(block) ? (
+
+
+
+ {t("aiClientConfigPlaceholderWarning")}
+
+
+ ) : null}
string,
auth: AiClientAuth,
- kind: "code" | "steps" = "code"
+ kind: "code" | "steps" = "code",
+ hasPlaceholders = false
): AiConfigBlock {
- return { id, label, kind, displayText: build(keyValue(auth)) };
+ return {
+ id,
+ label,
+ kind,
+ displayText: build(keyValue(auth)),
+ hasPlaceholders
+ };
+}
+
+const EXAMPLE_RESOURCE_HOST = "example.resource.url.com";
+
+export function aiConfigBlockHasPlaceholders(block: AiConfigBlock): boolean {
+ return (
+ block.hasPlaceholders === true ||
+ block.displayText.includes(EXAMPLE_RESOURCE_HOST)
+ );
}
function buildCli(
clientArg: "claude" | "codex" | "opencode",
auth: AiClientAuth,
resourceNiceId?: string
-): AiCliCommands {
- const resourceFlag = resourceNiceId
- ? ` --resource ${resourceNiceId}`
- : "";
+): AiConfigBlock[] {
+ const resourceFlag = resourceNiceId ? ` --resource ${resourceNiceId}` : "";
const configure: AiConfigBlock = {
id: `cli-configure-${clientArg}`,
@@ -73,17 +88,17 @@ function buildCli(
};
if (auth.mode !== "keyed") {
- return { configure };
+ return [configure];
}
- return {
+ return [
configure,
- configureWithKey: {
+ {
id: `cli-configure-key-${clientArg}`,
label: "Configure with an API key",
displayText: `pangolin configure ${clientArg} ${auth.key}${resourceFlag}`
}
- };
+ ];
}
function buildClaudeGuide(
@@ -132,7 +147,9 @@ function buildClaudeGuide(
" }",
"}"
].join("\n"),
- auth
+ auth,
+ "code",
+ true
);
const vertexSettings = block(
@@ -150,7 +167,9 @@ function buildClaudeGuide(
" }",
"}"
].join("\n"),
- auth
+ auth,
+ "code",
+ true
);
const kimiSettings = block(
@@ -171,7 +190,9 @@ function buildClaudeGuide(
" }",
"}"
].join("\n"),
- auth
+ auth,
+ "code",
+ true
);
return {
@@ -182,21 +203,25 @@ function buildClaudeGuide(
{
id: "default",
label: "Default (Anthropic)",
+ relation: "options",
blocks: [defaultSettings, defaultShell]
},
{
id: "bedrock",
label: "Amazon Bedrock",
+ relation: "options",
blocks: [bedrockSettings]
},
{
id: "vertex",
label: "Google Vertex AI",
+ relation: "options",
blocks: [vertexSettings]
},
{
id: "kimi",
label: "Kimi K2 (Moonshot AI)",
+ relation: "options",
blocks: [kimiSettings]
}
]
@@ -219,7 +244,9 @@ function buildCodexGuide(
'name = "Pangolin AI Gateway"',
`base_url = "${endpoint}/v1"`,
'wire_api = "responses"',
- ...(auth.mode === "keyed" ? ['env_key = "PANGOLIN_API_KEY"'] : [])
+ ...(auth.mode === "keyed"
+ ? ['env_key = "PANGOLIN_API_KEY"']
+ : [])
].join("\n"),
auth
);
@@ -242,6 +269,7 @@ function buildCodexGuide(
{
id: "default",
label: "Default",
+ relation: "steps",
blocks: shell ? [settings, shell] : [settings]
}
]
@@ -255,7 +283,7 @@ function buildOpencodeGuide(
): AiClientGuide {
const config = block(
"opencode-config",
- "Step 1: opencode.json",
+ "opencode.json",
() =>
[
"{",
@@ -279,11 +307,16 @@ function buildOpencodeGuide(
const authFile = block(
"opencode-auth",
- "Step 2: auth.json",
+ "auth.json",
(key) =>
- ["{", ' "anthropic": {', ' "type": "api",', ` "key": "${key}"`, " }", "}"].join(
- "\n"
- ),
+ [
+ "{",
+ ' "anthropic": {',
+ ' "type": "api",',
+ ` "key": "${key}"`,
+ " }",
+ "}"
+ ].join("\n"),
auth
);
@@ -295,6 +328,7 @@ function buildOpencodeGuide(
{
id: "default",
label: "Default",
+ relation: "steps",
blocks: [config, authFile]
}
]
@@ -316,7 +350,8 @@ function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
"5. Add a custom model matching the model your Pangolin AI Gateway serves (e.g. claude-sonnet-4-6)."
].join("\n"),
auth,
- "steps"
+ "steps",
+ true
);
return {
@@ -327,6 +362,7 @@ function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
{
id: "default",
label: "Default",
+ relation: "steps",
blocks: [steps]
}
]