add alert warning for configure coding agents

This commit is contained in:
miloschwartz
2026-08-19 10:24:53 -04:00
parent dc8e724482
commit 350de6ad2e
7 changed files with 264 additions and 119 deletions
+5 -1
View File
@@ -1786,12 +1786,16 @@
"aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.", "aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.",
"aiClientConfigDescriptionOpencode": "Open source terminal coding agent.", "aiClientConfigDescriptionOpencode": "Open source terminal coding agent.",
"aiClientConfigDescriptionCursor": "AI code editor built on VS Code.", "aiClientConfigDescriptionCursor": "AI code editor built on VS Code.",
"aiClientConfigSetup": "Setup",
"aiClientConfigTabCli": "Automatic (CLI)", "aiClientConfigTabCli": "Automatic (CLI)",
"aiClientConfigTabManual": "Manual Configuration", "aiClientConfigTabManual": "Manual Configuration",
"aiClientConfigPreset": "Configuration",
"aiClientConfigStep": "Step {number}",
"aiClientConfigEndpointPlaceholder": "https://example.resource.url.com", "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.", "aiClientConfigRevealError": "Could not load your API key.",
"aiClientConfigRevealRetry": "Try again", "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. <configLink>Learn more</configLink>",
"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",
@@ -28,6 +28,7 @@ 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 Link from "next/link";
import { ExternalLink } from "lucide-react";
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,17 +71,26 @@ export default function PrivateResourceGeneralPage() {
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t("privateResourceGeneralDescription")} {t("privateResourceGeneralDescription")}
{siteResource.mode === "inference" ? (
<>
{" "}
{t.rich(
"resourceGeneralAiClientConfigDescription",
{
configLink: (chunks) => (
<Link
href={`/${siteResource.orgId}?openResource=${encodeURIComponent(siteResource.niceId)}&openResourceQuery=${encodeURIComponent(siteResource.name)}`}
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
)
}
)}
</>
) : null}
</SettingsSectionDescription> </SettingsSectionDescription>
{siteResource.mode === "inference" ? (
<p className="text-sm pt-1">
<Link
href={`/${siteResource.orgId}?openResource=${encodeURIComponent(siteResource.niceId)}&openResourceQuery=${encodeURIComponent(siteResource.name)}`}
className="text-primary hover:underline"
>
{t("resourceGeneralAiClientConfigLink")}
</Link>
</p>
) : null}
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
@@ -50,6 +50,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import Link from "next/link"; import Link from "next/link";
import { ExternalLink } from "lucide-react";
import { build } from "@server/build"; import { build } from "@server/build";
import { TierFeature } from "@server/lib/billing/tierMatrix"; import { TierFeature } from "@server/lib/billing/tierMatrix";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
@@ -255,17 +256,26 @@ export default function GeneralForm() {
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t("resourceGeneralDescription")} {t("resourceGeneralDescription")}
{resource.mode === "inference" ? (
<>
{" "}
{t.rich(
"resourceGeneralAiClientConfigDescription",
{
configLink: (chunks) => (
<Link
href={`/${resource.orgId}?openResource=${encodeURIComponent(resource.niceId)}&openResourceQuery=${encodeURIComponent(resource.name)}`}
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
)
}
)}
</>
) : null}
</SettingsSectionDescription> </SettingsSectionDescription>
{resource.mode === "inference" ? (
<p className="text-sm pt-1">
<Link
href={`/${resource.orgId}?openResource=${encodeURIComponent(resource.niceId)}&openResourceQuery=${encodeURIComponent(resource.name)}`}
className="text-primary hover:underline"
>
{t("resourceGeneralAiClientConfigLink")}
</Link>
</p>
) : null}
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
@@ -1,25 +1,13 @@
"use client"; "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 { Button } from "@app/components/ui/button";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger CollapsibleTrigger
} from "@app/components/ui/collapsible"; } from "@app/components/ui/collapsible";
import { import { OptionSelect } from "@app/components/OptionSelect";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "@app/components/ui/tabs";
import type { import type {
AiClientAuthInput, AiClientAuthInput,
AiClientId, AiClientId,
@@ -43,6 +31,8 @@ type AiClientConfigCardProps = {
resourceNiceId?: string; resourceNiceId?: string;
}; };
type SetupMode = "cli" | "manual";
export function AiClientConfigCard({ export function AiClientConfigCard({
clientId, clientId,
name, name,
@@ -57,6 +47,7 @@ export function AiClientConfigCard({
const [resolvedIconSrc, setResolvedIconSrc] = useState(iconSrc.light); const [resolvedIconSrc, setResolvedIconSrc] = useState(iconSrc.light);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [presetId, setPresetId] = useState<AiClientPresetId>("default"); const [presetId, setPresetId] = useState<AiClientPresetId>("default");
const [setupMode, setSetupMode] = useState<SetupMode>("cli");
const [revealedKey, setRevealedKey] = useState<string | null>(null); const [revealedKey, setRevealedKey] = useState<string | null>(null);
const [revealing, setRevealing] = useState(false); const [revealing, setRevealing] = useState(false);
const [revealError, setRevealError] = useState(false); const [revealError, setRevealError] = useState(false);
@@ -120,29 +111,24 @@ export function AiClientConfigCard({
const manualContent = guide ? ( const manualContent = guide ? (
<div className="min-w-0 space-y-4"> <div className="min-w-0 space-y-4">
{guide.presets.length > 1 ? ( {guide.presets.length > 1 ? (
<Select <OptionSelect<AiClientPresetId>
value={presetId} label={t("aiClientConfigPreset")}
onValueChange={(value) => options={guide.presets.map((p) => ({
setPresetId(value as AiClientPresetId) value: p.id,
} label: p.label
> }))}
<SelectTrigger size="sm" className="max-w-xs"> value={preset?.id ?? presetId}
<SelectValue /> onChange={setPresetId}
</SelectTrigger> cols={2}
<SelectContent> />
{guide.presets.map((p) => ( ) : null}
<SelectItem key={p.id} value={p.id}> {preset ? (
{p.label} <AiConfigBlocks
</SelectItem> key={preset.id}
))} blocks={preset.blocks}
</SelectContent> relation={preset.relation}
</Select> />
) : null} ) : null}
<div className="grid min-w-0 gap-4">
{preset?.blocks.map((block) => (
<AiConfigCodeBlock key={block.id} block={block} />
))}
</div>
</div> </div>
) : null; ) : null;
@@ -191,35 +177,32 @@ export function AiClientConfigCard({
) : null} ) : null}
{guide ? ( {guide ? (
guide.cli ? ( guide.cli ? (
<Tabs defaultValue="cli"> <div className="min-w-0 space-y-4">
<TabsList> <OptionSelect<SetupMode>
<TabsTrigger value="cli"> label={t("aiClientConfigSetup")}
{t("aiClientConfigTabCli")} options={[
</TabsTrigger> {
<TabsTrigger value="manual"> value: "cli",
{t("aiClientConfigTabManual")} label: t("aiClientConfigTabCli")
</TabsTrigger> },
</TabsList> {
<TabsContent value: "manual",
value="cli" label: t("aiClientConfigTabManual")
className="grid min-w-0 gap-4 mt-4" }
> ]}
<AiConfigCodeBlock value={setupMode}
block={guide.cli.configure} onChange={setSetupMode}
cols={2}
/>
{setupMode === "cli" ? (
<AiConfigBlocks
blocks={guide.cli}
relation="options"
/> />
{guide.cli.configureWithKey ? ( ) : (
<AiConfigCodeBlock manualContent
block={guide.cli.configureWithKey} )}
/> </div>
) : null}
</TabsContent>
<TabsContent
value="manual"
className="min-w-0 mt-4"
>
{manualContent}
</TabsContent>
</Tabs>
) : ( ) : (
manualContent manualContent
) )
@@ -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<string>[] = 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 (
<div className="min-w-0 space-y-4">
<OptionSelect
label={t("method")}
options={methodOptions}
value={selected.id}
onChange={setSelectedId}
cols={2}
/>
<AiConfigCodeBlock block={selected} hideLabel />
</div>
);
}
const numberSteps = relation === "steps" && blocks.length > 1;
return (
<div className="grid min-w-0 gap-4">
{blocks.map((block, index) => (
<AiConfigCodeBlock
key={block.id}
block={block}
step={numberSteps ? index + 1 : undefined}
/>
))}
</div>
);
}
@@ -1,23 +1,53 @@
"use client"; "use client";
import CopyTextBox from "@app/components/CopyTextBox"; 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 { 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 ( return (
<div className="min-w-0 space-y-1.5"> <div className="min-w-0 space-y-1.5">
<p className="font-mono text-xs text-muted-foreground"> {!hideLabel ? (
{block.label} <p className="font-mono text-xs text-muted-foreground">
</p> {label}
</p>
) : null}
<div <div
className={cn( className={cn(
"min-w-0", "min-w-0",
block.kind === "steps" block.kind !== "steps" && "[&_code]:font-mono"
? "[&_pre]:text-sm"
: "[&_pre]:text-xs [&_code]:font-mono"
)} )}
> >
{aiConfigBlockHasPlaceholders(block) ? (
<Alert variant="neutral" className="mb-3">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("aiClientConfigPlaceholderWarning")}
</AlertDescription>
</Alert>
) : null}
<CopyTextBox <CopyTextBox
text={block.displayText} text={block.displayText}
wrapText={block.kind === "steps"} wrapText={block.kind === "steps"}
+62 -26
View File
@@ -21,25 +21,26 @@ export type AiConfigBlock = {
label: string; label: string;
kind?: "code" | "steps"; kind?: "code" | "steps";
displayText: string; displayText: string;
/** True when the snippet includes example values the user must replace. */
hasPlaceholders?: boolean;
}; };
export type AiClientPresetId = "default" | "bedrock" | "vertex" | "kimi"; export type AiClientPresetId = "default" | "bedrock" | "vertex" | "kimi";
export type AiConfigRelation = "options" | "steps";
export type AiConfigPreset = { export type AiConfigPreset = {
id: AiClientPresetId; id: AiClientPresetId;
label: string; label: string;
relation: AiConfigRelation;
blocks: AiConfigBlock[]; blocks: AiConfigBlock[];
}; };
export type AiCliCommands = {
configure: AiConfigBlock;
configureWithKey?: AiConfigBlock;
};
export type AiClientGuide = { export type AiClientGuide = {
id: AiClientId; id: AiClientId;
name: string; name: string;
cli: AiCliCommands | null; /** Alternative CLI commands. Empty/null means this client has no CLI setup. */
cli: AiConfigBlock[] | null;
presets: AiConfigPreset[]; presets: AiConfigPreset[];
}; };
@@ -52,19 +53,33 @@ function block(
label: string, label: string,
build: (keyValue: string) => string, build: (keyValue: string) => string,
auth: AiClientAuth, auth: AiClientAuth,
kind: "code" | "steps" = "code" kind: "code" | "steps" = "code",
hasPlaceholders = false
): AiConfigBlock { ): 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( function buildCli(
clientArg: "claude" | "codex" | "opencode", clientArg: "claude" | "codex" | "opencode",
auth: AiClientAuth, auth: AiClientAuth,
resourceNiceId?: string resourceNiceId?: string
): AiCliCommands { ): AiConfigBlock[] {
const resourceFlag = resourceNiceId const resourceFlag = resourceNiceId ? ` --resource ${resourceNiceId}` : "";
? ` --resource ${resourceNiceId}`
: "";
const configure: AiConfigBlock = { const configure: AiConfigBlock = {
id: `cli-configure-${clientArg}`, id: `cli-configure-${clientArg}`,
@@ -73,17 +88,17 @@ function buildCli(
}; };
if (auth.mode !== "keyed") { if (auth.mode !== "keyed") {
return { configure }; return [configure];
} }
return { return [
configure, configure,
configureWithKey: { {
id: `cli-configure-key-${clientArg}`, id: `cli-configure-key-${clientArg}`,
label: "Configure with an API key", label: "Configure with an API key",
displayText: `pangolin configure ${clientArg} ${auth.key}${resourceFlag}` displayText: `pangolin configure ${clientArg} ${auth.key}${resourceFlag}`
} }
}; ];
} }
function buildClaudeGuide( function buildClaudeGuide(
@@ -132,7 +147,9 @@ function buildClaudeGuide(
" }", " }",
"}" "}"
].join("\n"), ].join("\n"),
auth auth,
"code",
true
); );
const vertexSettings = block( const vertexSettings = block(
@@ -150,7 +167,9 @@ function buildClaudeGuide(
" }", " }",
"}" "}"
].join("\n"), ].join("\n"),
auth auth,
"code",
true
); );
const kimiSettings = block( const kimiSettings = block(
@@ -171,7 +190,9 @@ function buildClaudeGuide(
" }", " }",
"}" "}"
].join("\n"), ].join("\n"),
auth auth,
"code",
true
); );
return { return {
@@ -182,21 +203,25 @@ function buildClaudeGuide(
{ {
id: "default", id: "default",
label: "Default (Anthropic)", label: "Default (Anthropic)",
relation: "options",
blocks: [defaultSettings, defaultShell] blocks: [defaultSettings, defaultShell]
}, },
{ {
id: "bedrock", id: "bedrock",
label: "Amazon Bedrock", label: "Amazon Bedrock",
relation: "options",
blocks: [bedrockSettings] blocks: [bedrockSettings]
}, },
{ {
id: "vertex", id: "vertex",
label: "Google Vertex AI", label: "Google Vertex AI",
relation: "options",
blocks: [vertexSettings] blocks: [vertexSettings]
}, },
{ {
id: "kimi", id: "kimi",
label: "Kimi K2 (Moonshot AI)", label: "Kimi K2 (Moonshot AI)",
relation: "options",
blocks: [kimiSettings] blocks: [kimiSettings]
} }
] ]
@@ -219,7 +244,9 @@ function buildCodexGuide(
'name = "Pangolin AI Gateway"', 'name = "Pangolin AI Gateway"',
`base_url = "${endpoint}/v1"`, `base_url = "${endpoint}/v1"`,
'wire_api = "responses"', 'wire_api = "responses"',
...(auth.mode === "keyed" ? ['env_key = "PANGOLIN_API_KEY"'] : []) ...(auth.mode === "keyed"
? ['env_key = "PANGOLIN_API_KEY"']
: [])
].join("\n"), ].join("\n"),
auth auth
); );
@@ -242,6 +269,7 @@ function buildCodexGuide(
{ {
id: "default", id: "default",
label: "Default", label: "Default",
relation: "steps",
blocks: shell ? [settings, shell] : [settings] blocks: shell ? [settings, shell] : [settings]
} }
] ]
@@ -255,7 +283,7 @@ function buildOpencodeGuide(
): AiClientGuide { ): AiClientGuide {
const config = block( const config = block(
"opencode-config", "opencode-config",
"Step 1: opencode.json", "opencode.json",
() => () =>
[ [
"{", "{",
@@ -279,11 +307,16 @@ function buildOpencodeGuide(
const authFile = block( const authFile = block(
"opencode-auth", "opencode-auth",
"Step 2: auth.json", "auth.json",
(key) => (key) =>
["{", ' "anthropic": {', ' "type": "api",', ` "key": "${key}"`, " }", "}"].join( [
"\n" "{",
), ' "anthropic": {',
' "type": "api",',
` "key": "${key}"`,
" }",
"}"
].join("\n"),
auth auth
); );
@@ -295,6 +328,7 @@ function buildOpencodeGuide(
{ {
id: "default", id: "default",
label: "Default", label: "Default",
relation: "steps",
blocks: [config, authFile] 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)." "5. Add a custom model matching the model your Pangolin AI Gateway serves (e.g. claude-sonnet-4-6)."
].join("\n"), ].join("\n"),
auth, auth,
"steps" "steps",
true
); );
return { return {
@@ -327,6 +362,7 @@ function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
{ {
id: "default", id: "default",
label: "Default", label: "Default",
relation: "steps",
blocks: [steps] blocks: [steps]
} }
] ]