Add multi-provider AI settings panel to a JB Cloud Next.js app. Copies AIProvidersPanel component from ~/clarity, creates API routes per provider, adds integrations table to schema, and wires up the settings page. Use when an app needs user-managed AI API keys with encrypted storage and automatic fallback.
Adds a settings panel for managing encrypted AI provider API keys with automatic fallback routing.
/plugin marketplace add https://www.claudepluginhub.com/api/plugins/aventerica89-claude-codex/marketplace.json/plugin install aventerica89-claude-codex@cpd-aventerica89-claude-codexThis skill inherits all available tools. When active, it can use any tool Claude has access to.
/add-ai-providers — implement the full AI provider settings panel in the current project/add-ai-providers:update — bring an existing AI providers panel up to date with the latest Clarity patternCopies the Clarity AIProvidersPanel component pattern into the current Next.js project. Providers are listed as expandable card rows; the first connected provider is the active model, the rest are automatic fallbacks. Keys are encrypted at rest.
Source of truth: ~/clarity/src/components/settings/ai-providers-panel.tsx
Read CLAUDE.md and package.json to confirm:
src/app/(dashboard)/settings/page.tsx)If the stack differs significantly from Clarity, adapt accordingly and note changes.
Read the source component from Clarity:
~/clarity/src/components/settings/ai-providers-panel.tsx
Write it to the current project:
src/components/settings/ai-providers-panel.tsx
Adapt the PROVIDERS array if needed:
Add logoSrc?: string to ProviderConfig and set it for providers that have logos:
interface ProviderConfig {
id: ProviderId
label: string
model: string
description: string
placeholder: string
docsUrl: string
avatarColor: string
initial: string
logoSrc?: string // path to SVG in public/logos/; absent = colored initial
}
const PROVIDERS: ProviderConfig[] = [
{ id: "anthropic", label: "Claude", logoSrc: "/logos/claude-logo.svg",
avatarColor: "bg-violet-500", initial: "A", ... },
{ id: "gemini", label: "Gemini", logoSrc: "/logos/google-logo.svg",
avatarColor: "bg-blue-500", initial: "G", ... },
{ id: "deepseek", label: "DeepSeek",
avatarColor: "bg-teal-500", initial: "D", ... },
{ id: "groq", label: "Groq", logoSrc: "/logos/groq-logo.svg",
// Summary-only role: fast/free but hallucinates tool calls
// description: "Conversation summaries only — fast free tier"
// model: "llama-3.1-8b-instant" (summary model)
avatarColor: "bg-orange-500", initial: "Gr", ... },
]
Update the avatar render in the component to conditionally show the logo:
{provider.logoSrc ? (
<img
src={provider.logoSrc}
alt={provider.label}
className="w-8 h-8 rounded-lg object-contain shrink-0"
/>
) : (
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-semibold shrink-0",
provider.avatarColor
)}>
{provider.initial}
</div>
)}
Keep all component logic, state patterns, and CSS classes identical unless adapting for a non-shadcn/non-Tailwind project.
Logos are stored in the claude-codex assets directory (~/.claude/assets/logos/). Copy the three square-icon logos to the target project:
mkdir -p public/logos
cp ~/.claude/assets/logos/claude-logo.svg public/logos/
cp ~/.claude/assets/logos/google-logo.svg public/logos/
cp ~/.claude/assets/logos/groq-logo.svg public/logos/
DeepSeek has a logo (DeepSeek_idPu03Khfd_1.svg) but it is a wide wordmark — not suitable for an 8×8 avatar. Use the teal "D" initial instead.
Logo notes:
claude-logo.svg — coral swirl mark (#D97757), transparent backgroundgoogle-logo.svg — multicolor Google "G", transparent backgroundgroq-logo.svg — red square background (#F54F35) with white "Q" mark baked inDeepSeek_idPu03Khfd_1.svg — blue wordmark (icon + text), wide aspect ratio, use initial at small sizesCheck if the integrations table already exists in src/lib/schema.ts (or equivalent). If not, add:
export const integrations = sqliteTable("integrations", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
accessTokenEncrypted: text("access_token_encrypted"),
refreshTokenEncrypted: text("refresh_token_encrypted"),
tokenExpiresAt: integer("token_expires_at", { mode: "timestamp" }),
providerAccountId: text("provider_account_id"),
config: text("config").notNull().default("{}"),
syncStatus: text("sync_status").notNull().default("idle"),
lastSyncedAt: integer("last_synced_at", { mode: "timestamp" }),
lastError: text("last_error"),
}, (t) => [
uniqueIndex("integrations_user_provider_idx").on(t.userId, t.provider),
])
Then create the migration. For Turso/Drizzle:
npx drizzle-kit generate
Run migration via Turso HTTP API if drizzle-kit push has schema drift warnings.
Check src/lib/crypto.ts for encryptToken(). If it doesn't exist, copy from Clarity:
~/clarity/src/lib/crypto.ts
Verify the encryption key env var name (ENCRYPTION_KEY or similar) and add it to .env.example.
For each provider in the PROVIDERS array, create a route file. Read the Anthropic route from Clarity as a template:
~/clarity/src/app/api/integrations/anthropic/route.ts
Create one file per provider, substituting the provider string:
src/app/api/integrations/anthropic/route.ts
src/app/api/integrations/gemini/route.ts
src/app/api/integrations/deepseek/route.ts
src/app/api/integrations/groq/route.ts
Each route is identical except for the provider: string in the DB insert/delete.
Route pattern (POST + DELETE):
import { NextRequest, NextResponse } from "next/server"
import { headers } from "next/headers"
import { and, eq } from "drizzle-orm"
import { z } from "zod"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { integrations } from "@/lib/schema"
import { encryptToken } from "@/lib/crypto"
const saveSchema = z.object({ token: z.string().min(1) })
export async function POST(request: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const parsed = saveSchema.safeParse(await request.json())
if (!parsed.success) return NextResponse.json({ error: "Token is required" }, { status: 400 })
const encrypted = encryptToken(parsed.data.token)
await db.insert(integrations)
.values({ userId: session.user.id, provider: "PROVIDER_NAME", accessTokenEncrypted: encrypted, syncStatus: "idle" })
.onConflictDoUpdate({
target: [integrations.userId, integrations.provider],
set: { accessTokenEncrypted: encrypted, syncStatus: "idle", lastError: null },
})
return NextResponse.json({ ok: true })
}
export async function DELETE() {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
await db.delete(integrations)
.where(and(eq(integrations.userId, session.user.id), eq(integrations.provider, "PROVIDER_NAME")))
return NextResponse.json({ ok: true })
}
In the settings Server Component, add parallel queries for each provider and pass connected state to the panel:
import { AIProvidersPanel } from "@/components/settings/ai-providers-panel"
// Inside the Server Component, alongside existing queries:
const [anthropicRows, geminiRows, deepseekRows, groqRows] = await Promise.all([
db.select().from(integrations).where(and(eq(integrations.userId, userId), eq(integrations.provider, "anthropic"))),
db.select().from(integrations).where(and(eq(integrations.userId, userId), eq(integrations.provider, "gemini"))),
db.select().from(integrations).where(and(eq(integrations.userId, userId), eq(integrations.provider, "deepseek"))),
db.select().from(integrations).where(and(eq(integrations.userId, userId), eq(integrations.provider, "groq"))),
])
const aiConnected = {
anthropic: anthropicRows.length > 0,
gemini: geminiRows.length > 0,
deepseek: deepseekRows.length > 0,
groq: groqRows.length > 0,
}
// In JSX:
<AIProvidersPanel connected={aiConnected} />
Place the panel inside the existing settings layout, typically after the primary integration card.
Summarize what was created:
AI provider settings added!
Component: src/components/settings/ai-providers-panel.tsx
API routes: src/app/api/integrations/{anthropic,gemini,deepseek,groq}/route.ts
Schema: integrations table added to src/lib/schema.ts
Migration: generated — run npx drizzle-kit push or use Turso HTTP API
Providers configured:
- Claude (Anthropic) logo: claude-logo.svg
- Gemini logo: google-logo.svg
- DeepSeek initial: D bg-teal-500
- Groq logo: groq-logo.svg [summary-only]
Next: add ENCRYPTION_KEY env var if not already present
Next: run /add-ai-providers runtime or ai-provider-setup agent to wire the chat side
The steps above handle the settings panel — how users store and manage their API keys. Steps 8-12 handle the runtime side — how the app actually calls those providers in a chat system.
Source of truth for runtime: ~/wp-dispatch (canonical) | Source of truth for settings panel: ~/clarity
npm install ai @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/google @ai-sdk/groq
Create src/lib/ai-provider.ts. Read canonical file from ~/wp-dispatch/src/lib/ai-provider.ts.
Exports:
getActiveProvider() — resolves first configured provider in priority order: deepseek → groq → gemini → anthropic. Groq is skipped for main chat (summary-only).getProviderByName(name) — user override (excludes groq)getSummaryProvider() — Groq if configured (llama-3.1-8b-instant), else nullModel maps:
const CHAT_MODELS: Record<ProviderName, string> = {
deepseek: "deepseek-chat",
groq: "llama-3.3-70b-versatile",
gemini: "gemini-2.0-flash",
anthropic: "claude-sonnet-4-5-20250929",
}
const SUMMARY_MODELS: Record<ProviderName, string> = {
deepseek: "deepseek-chat",
groq: "llama-3.1-8b-instant",
gemini: "gemini-1.5-flash",
anthropic: "claude-haiku-4-5-20251001",
}
Per-provider SDK wiring (exact patterns — do not deviate):
// DeepSeek — MUST use .chat(), NOT client()
// Default createOpenAI routes to Responses API which DeepSeek does not support
const client = createOpenAI({ apiKey: key, baseURL: "https://api.deepseek.com/v1" })
return { chatModel: client.chat("deepseek-chat"), summaryModel: client.chat("deepseek-chat") }
// Groq — reserved for summaries only; hallucinates tool call results
const client = createGroq({ apiKey: key })
return { chatModel: client("llama-3.3-70b-versatile"), summaryModel: client("llama-3.1-8b-instant") }
// Gemini — place last in priority (free tier quota-prone)
const client = createGoogleGenerativeAI({ apiKey: key })
return { chatModel: client("gemini-2.0-flash"), summaryModel: client("gemini-1.5-flash") }
// Anthropic — OAuth tokens are rejected by Messages API; skip them
if (key.startsWith("sk-ant-oat")) continue // skip OAuth tokens
const client = createAnthropic({ apiKey: key })
return { chatModel: client("claude-sonnet-4-5-20250929"), summaryModel: client("claude-haiku-4-5-20251001") }
Create src/lib/claude-chat.ts. Read canonical file from ~/wp-dispatch/src/lib/claude-chat.ts.
Key patterns:
import { generateText, stepCountIs } from "ai"
// Main agent loop — provider-agnostic
const result = await generateText({
model: provider.chatModel,
system: systemPrompt,
messages,
tools, // undefined = no tools
stopWhen: stepCountIs(8), // NOT maxSteps (deprecated)
abortSignal: AbortSignal.timeout(45_000),
})
Groq retry without tools (failed_generation):
if (msg.includes("failed_generation") && tools) {
// Groq can't format tool calls — retry as plain text
const fallback = await generateText({ model, system, messages })
return { response: fallback.text }
}
Error classification (user-friendly messages):
if (msg.includes("quota") || msg.includes("exceeded"))
→ "{provider} quota exceeded. Upgrade or switch providers in Settings > AI."
if (msg.includes("not found") || msg.includes("404"))
→ "{provider} model not found ({modelId}). May be deprecated — check Settings > AI."
if (msg.includes("rate limit") || msg.includes("429"))
→ "{provider} rate limit hit. Wait a moment and try again."
Non-blocking summary (fire-and-forget after response returned):
const summaryModel = (await getSummaryProvider()) ?? provider.summaryModel
generateConversationSummary(summaryModel, message, result.response)
.then(async (summary) => { /* update DB row */ })
.catch((err) => { console.error("Summary failed:", err) })
Create src/app/api/chat/providers/route.ts. Read canonical from ~/wp-dispatch/src/app/api/chat/providers/route.ts.
Returns { active, available, groqSummary } without decrypting keys. Groq is filtered from available (summary-only role).
const CHAT_PROVIDERS = ["deepseek", "gemini", "anthropic"] as const // groq excluded
// ...
return NextResponse.json({ active, available, groqSummary })
In src/app/api/chat/route.ts, add providerOverride to the Zod schema and resolution logic:
const chatRequestSchema = z.object({
message: z.string().min(1),
providerOverride: z.enum(["deepseek", "gemini", "anthropic"]).optional(), // groq excluded
// ... app-specific fields
})
// Resolution
const provider = providerOverride
? await getProviderByName(providerOverride as ProviderName) ?? await getActiveProvider()
: await getActiveProvider()
// Return provider name so UI can show branded avatar
return NextResponse.json({
response: result.response,
providerName: provider.name,
// ...
})
| Provider | Gotcha | Fix |
|---|---|---|
| DeepSeek | Default createOpenAI routes to Responses API | Use client.chat("deepseek-chat") not client("deepseek-chat") |
| Groq | Hallucinates tool call results (failed_generation) | Summary-only; on failed_generation, retry without tools |
| Anthropic | OAuth tokens (sk-ant-oat*) rejected by Messages API | Filter sk-ant-oat* before creating client |
| Gemini | Free tier quota exhaustion | Place last in priority order |
| AI SDK v3 | compatibility option removed | Use .chat() method instead |
| AI SDK | maxSteps deprecated | Use stopWhen: stepCountIs(n) |
encryptToken()session.user.id before any DB operationsk-ant-oat...) work only inside Claude CLI — apps calling Messages API directly need sk-ant-api...~/clarity/src/components/settings/ai-providers-panel.tsx/add-ai-providers:update)Brings an existing AI providers panel up to date with the latest Clarity pattern. Run this in projects that already have the panel implemented but may be behind on:
Read the current component:
src/components/settings/ai-providers-panel.tsx
Check for:
ProviderConfig have logoSrc?: string?public/logos/?Add logoSrc if missing:
Add to ProviderConfig interface:
logoSrc?: string // path to SVG in public/logos/; absent = colored initial
Update PROVIDERS entries with current models and logos:
| Provider | Current model | logoSrc |
|---|---|---|
| anthropic | claude-sonnet-4-6 | /logos/claude-logo.svg |
| gemini | gemini-2.0-flash | /logos/google-logo.svg |
| deepseek | deepseek-chat | (none) |
| groq | llama-3.3-70b-versatile | /logos/groq-logo.svg |
Update avatar render if it uses the old colored-div-only pattern:
{provider.logoSrc ? (
<img
src={provider.logoSrc}
alt={provider.label}
className="w-8 h-8 rounded-lg object-contain shrink-0"
/>
) : (
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-semibold shrink-0",
provider.avatarColor
)}>
{provider.initial}
</div>
)}
Check public/logos/ for each logo. Copy any that are missing:
mkdir -p public/logos
# Copy only if not already present
[ -f public/logos/claude-logo.svg ] || cp ~/.claude/assets/logos/claude-logo.svg public/logos/
[ -f public/logos/google-logo.svg ] || cp ~/.claude/assets/logos/google-logo.svg public/logos/
[ -f public/logos/groq-logo.svg ] || cp ~/.claude/assets/logos/groq-logo.svg public/logos/
AI providers panel updated!
Changes applied:
- ProviderConfig: added logoSrc field [or: already present]
- Avatar render: updated to logo/initial [or: already updated]
- Model names: updated to current versions [or: already current]
- Logos copied to public/logos/:
claude-logo.svg [copied / already present]
google-logo.svg [copied / already present]
groq-logo.svg [copied / already present]
No schema or API route changes needed.
Activates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.
Search, retrieve, and install Agent Skills from the prompts.chat registry using MCP tools. Use when the user asks to find skills, browse skill catalogs, install a skill for Claude, or extend Claude's capabilities with reusable AI agent components.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.