Give members their own budget-capped gateway key
Members share the owner's AI budget, so each now gets a Switchboard key minted on first use with a daily cap and a per-request ceiling. That gives real spend limits and per-user attribution: the app's own rate limiter lives in memory and resets on every deploy, so it could never be a spend control. The gateway enforces the caps and answers 402 guardrail, which the error mapper already turns into a budget message. Resolution order is the user's own key, then mint, then borrow the owner's key for a single request if the gateway is unreachable - a served request beats a hard failure, and the fallback logs loudly because no per-user cap applies to it. The owner key is used only for minting, never for inference. API key management is now owner-only, enforced on GET, POST and DELETE. GET matters as much as POST because it returns the masked key and the gateway URL. Settings became a server component so the role is known before first render: members never see the card and never issue the request, rather than having it flash and disappear. AiCall records one row per request from the gateway's own response metadata, so member spend is queryable instead of a journald grep. The write is fire-and-forget - tracking must never fail a working request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ model User {
|
|||||||
recipes Recipe[]
|
recipes Recipe[]
|
||||||
flavorProfile FlavorProfile?
|
flavorProfile FlavorProfile?
|
||||||
searchCache SearchCache[]
|
searchCache SearchCache[]
|
||||||
|
aiCalls AiCall[]
|
||||||
|
|
||||||
invitesCreated Invite[] @relation("InviteCreator")
|
invitesCreated Invite[] @relation("InviteCreator")
|
||||||
redemption InviteRedemption?
|
redemption InviteRedemption?
|
||||||
@@ -155,6 +156,9 @@ model UserApiKey {
|
|||||||
encryptedKey String @db.Text
|
encryptedKey String @db.Text
|
||||||
iv String // initialization vector for decryption
|
iv String // initialization vector for decryption
|
||||||
label String? // optional user-friendly label
|
label String? // optional user-friendly label
|
||||||
|
// Gateway-side id of a key this app minted, so it can be revoked when the
|
||||||
|
// account is deleted. Null for a key the owner pasted in by hand.
|
||||||
|
gatewayKeyId String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -164,6 +168,26 @@ model UserApiKey {
|
|||||||
@@unique([userId, provider])
|
@@unique([userId, provider])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row per AI request, written from the gateway's response metadata. Turns
|
||||||
|
/// "who is spending my money" into a query rather than a journald grep, now that
|
||||||
|
/// members share the owner's budget.
|
||||||
|
model AiCall {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
feature String // e.g. "menu.extract"
|
||||||
|
modelId String? // model the gateway actually routed to
|
||||||
|
provider String?
|
||||||
|
costUsd Float?
|
||||||
|
latencyMs Int?
|
||||||
|
failover Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model UserPreference {
|
model UserPreference {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
|
|||||||
@@ -1,421 +1,15 @@
|
|||||||
"use client"
|
import { redirect } from "next/navigation"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
import { useState } from "react"
|
import { SettingsClient } from "@/components/settings/settings-client"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { Header } from "@/components/layout/header"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { Key, Trash2, Check, Loader2, Shield, Sliders } from "lucide-react"
|
|
||||||
import { BackupRestore } from "@/components/settings/backup-restore"
|
|
||||||
|
|
||||||
interface ApiKeyInfo {
|
|
||||||
id: string
|
|
||||||
provider: string
|
|
||||||
label?: string
|
|
||||||
maskedKey: string
|
|
||||||
isActive: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiKeysResponse {
|
|
||||||
keys: ApiKeyInfo[]
|
|
||||||
gatewayUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
// API Keys
|
|
||||||
const { data: apiKeyData } = useQuery<ApiKeysResponse>({
|
|
||||||
queryKey: ["api-keys"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await fetch("/api/settings/api-keys")
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch API keys")
|
|
||||||
return res.json()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const apiKeys = apiKeyData?.keys ?? []
|
|
||||||
const legacyKeys = apiKeys.filter(
|
|
||||||
(k) => k.provider === "claude" || k.provider === "openai"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Preferences
|
|
||||||
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
|
||||||
queryKey: ["preferences"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await fetch("/api/settings/preferences")
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch preferences")
|
|
||||||
return res.json()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const savePreferences = useMutation({
|
|
||||||
mutationFn: async (prefs: Record<string, unknown>) => {
|
|
||||||
const res = await fetch("/api/settings/preferences", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(prefs),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error("Failed to save preferences")
|
|
||||||
return res.json()
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["preferences"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Header title="Settings" />
|
|
||||||
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Manage your AI providers and preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Keys Section */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Key className="h-5 w-5" />
|
|
||||||
AI Gateway
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
AI features route through Switchboard, which picks the best model for each
|
|
||||||
request. Add your gateway API key below — it is encrypted before storage.
|
|
||||||
{apiKeyData?.gatewayUrl && (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
This app is pointed at{" "}
|
|
||||||
<code className="text-xs">{apiKeyData.gatewayUrl}</code>.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<ApiKeyForm
|
|
||||||
provider="switchboard"
|
|
||||||
label="Switchboard Gateway"
|
|
||||||
existingKey={apiKeys.find((k) => k.provider === "switchboard")}
|
|
||||||
/>
|
|
||||||
{legacyKeys.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<LegacyKeyNotice keys={legacyKeys} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Preferences Section */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Sliders className="h-5 w-5" />
|
|
||||||
Drink Preferences
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Help the AI make better recommendations by telling it what you like
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<PreferencesForm
|
|
||||||
preferences={preferences}
|
|
||||||
isLoading={prefsLoading}
|
|
||||||
onSave={(prefs) => savePreferences.mutate(prefs)}
|
|
||||||
isSaving={savePreferences.isPending}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Backup & Restore Section */}
|
|
||||||
<BackupRestore />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEGACY_PROVIDER_LABELS: Record<string, string> = {
|
|
||||||
claude: "Anthropic Claude",
|
|
||||||
openai: "OpenAI",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys left over from when the app called Claude and OpenAI directly. They are
|
* Server component so the role is known before the first render. Passing isOwner
|
||||||
* already ignored when picking a provider, but they are shown here so a user who
|
* down avoids the flash of an AI Gateway card that useSession() alone would give,
|
||||||
* still has one can see it is inert and remove it.
|
* and lets the client skip the owner-only key request entirely.
|
||||||
*/
|
*/
|
||||||
function LegacyKeyNotice({ keys }: { keys: ApiKeyInfo[] }) {
|
export default async function SettingsPage() {
|
||||||
const queryClient = useQueryClient()
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) redirect("/login")
|
||||||
|
|
||||||
const deleteKey = useMutation({
|
return <SettingsClient isOwner={session.user.role === "OWNER"} />
|
||||||
mutationFn: async (provider: string) => {
|
|
||||||
const res = await fetch(`/api/settings/api-keys?provider=${provider}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error("Failed to delete API key")
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-dashed p-3 space-y-3">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
These keys are from an earlier version that called each AI provider directly.
|
|
||||||
They are no longer used and can be removed.
|
|
||||||
</p>
|
|
||||||
{keys.map((key) => (
|
|
||||||
<div key={key.id} className="flex items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{LEGACY_PROVIDER_LABELS[key.provider] ?? key.provider}
|
|
||||||
</p>
|
|
||||||
<code className="text-xs bg-muted px-2 py-0.5 rounded">
|
|
||||||
{key.maskedKey}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => deleteKey.mutate(key.provider)}
|
|
||||||
disabled={deleteKey.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-1 text-destructive" />
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApiKeyForm({
|
|
||||||
provider,
|
|
||||||
label,
|
|
||||||
existingKey,
|
|
||||||
}: {
|
|
||||||
provider: string
|
|
||||||
label: string
|
|
||||||
existingKey?: ApiKeyInfo
|
|
||||||
}) {
|
|
||||||
const [apiKey, setApiKey] = useState("")
|
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const saveKey = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const res = await fetch("/api/settings/api-keys", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ provider, apiKey }),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error("Failed to save API key")
|
|
||||||
return res.json()
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
|
|
||||||
setApiKey("")
|
|
||||||
setIsEditing(false)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteKey = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const res = await fetch(`/api/settings/api-keys?provider=${provider}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error("Failed to delete API key")
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingKey && !isEditing) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{label}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<code className="text-sm bg-muted px-2 py-0.5 rounded">
|
|
||||||
{existingKey.maskedKey}
|
|
||||||
</code>
|
|
||||||
<Badge variant="outline" className="text-xs text-green-600">
|
|
||||||
<Check className="h-3 w-3 mr-1" />
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => deleteKey.mutate()}
|
|
||||||
disabled={deleteKey.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="font-medium">{label}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder={`Enter your ${label} API key`}
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() => saveKey.mutate()}
|
|
||||||
disabled={!apiKey || saveKey.isPending}
|
|
||||||
>
|
|
||||||
{saveKey.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Save"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{isEditing && (
|
|
||||||
<Button variant="ghost" onClick={() => setIsEditing(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
<Shield className="h-3 w-3" />
|
|
||||||
Your key is encrypted before storage and never exposed in full
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreferencesForm({
|
|
||||||
preferences,
|
|
||||||
isLoading,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: {
|
|
||||||
preferences: Record<string, unknown> | undefined
|
|
||||||
isLoading: boolean
|
|
||||||
onSave: (prefs: Record<string, unknown>) => void
|
|
||||||
isSaving: boolean
|
|
||||||
}) {
|
|
||||||
const [preferredStyles, setPreferredStyles] = useState("")
|
|
||||||
const [avoidedStyles, setAvoidedStyles] = useState("")
|
|
||||||
const [minAbv, setMinAbv] = useState("")
|
|
||||||
const [maxAbv, setMaxAbv] = useState("")
|
|
||||||
const [initialized, setInitialized] = useState(false)
|
|
||||||
|
|
||||||
if (preferences && !initialized) {
|
|
||||||
const prefs = preferences as { preferredStyles?: string[]; avoidedStyles?: string[]; minAbv?: number; maxAbv?: number }
|
|
||||||
setPreferredStyles(prefs.preferredStyles?.join(", ") || "")
|
|
||||||
setAvoidedStyles(prefs.avoidedStyles?.join(", ") || "")
|
|
||||||
setMinAbv(prefs.minAbv?.toString() || "")
|
|
||||||
setMaxAbv(prefs.maxAbv?.toString() || "")
|
|
||||||
setInitialized(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
onSave({
|
|
||||||
preferredStyles: preferredStyles
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
avoidedStyles: avoidedStyles
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
minAbv: minAbv ? parseFloat(minAbv) : null,
|
|
||||||
maxAbv: maxAbv ? parseFloat(maxAbv) : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) return <div className="space-y-3"><p className="text-sm text-muted-foreground">Loading...</p></div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="preferred">Preferred Styles</Label>
|
|
||||||
<Input
|
|
||||||
id="preferred"
|
|
||||||
placeholder="e.g., IPA, Stout, Pinot Noir, Malbec"
|
|
||||||
value={preferredStyles}
|
|
||||||
onChange={(e) => setPreferredStyles(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Comma-separated list of styles you enjoy
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="avoided">Avoided Styles</Label>
|
|
||||||
<Input
|
|
||||||
id="avoided"
|
|
||||||
placeholder="e.g., Sour, Light Lager, Rosé"
|
|
||||||
value={avoidedStyles}
|
|
||||||
onChange={(e) => setAvoidedStyles(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Comma-separated list of styles you want to avoid
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="minAbv">Min ABV %</Label>
|
|
||||||
<Input
|
|
||||||
id="minAbv"
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
placeholder="e.g., 4.0"
|
|
||||||
value={minAbv}
|
|
||||||
onChange={(e) => setMinAbv(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="maxAbv">Max ABV %</Label>
|
|
||||||
<Input
|
|
||||||
id="maxAbv"
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
placeholder="e.g., 12.0"
|
|
||||||
value={maxAbv}
|
|
||||||
onChange={(e) => setMaxAbv(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={isSaving}>
|
|
||||||
{isSaving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Save Preferences"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { requireOwner } from "@/lib/authz"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { encrypt, decrypt, maskApiKey } from "@/lib/encryption"
|
import { encrypt, decrypt, maskApiKey } from "@/lib/encryption"
|
||||||
import { apiKeySchema } from "@/lib/validators"
|
import { apiKeySchema } from "@/lib/validators"
|
||||||
import { switchboardBaseUrl } from "@/lib/ai/switchboard-provider"
|
import { switchboardBaseUrl } from "@/lib/ai/switchboard-provider"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth()
|
// Owner only: members share the owner's gateway budget via a minted key and never
|
||||||
if (!session?.user?.id) {
|
// manage one themselves. GET matters as much as POST - it returns the masked key.
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
const session = await requireOwner()
|
||||||
}
|
if (session instanceof NextResponse) return session
|
||||||
|
|
||||||
const apiKeys = await prisma.userApiKey.findMany({
|
const apiKeys = await prisma.userApiKey.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
@@ -51,10 +51,10 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth()
|
// Owner only: members share the owner's gateway budget via a minted key and never
|
||||||
if (!session?.user?.id) {
|
// manage one themselves. GET matters as much as POST - it returns the masked key.
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
const session = await requireOwner()
|
||||||
}
|
if (session instanceof NextResponse) return session
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
@@ -119,10 +119,10 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
const session = await auth()
|
// Owner only: members share the owner's gateway budget via a minted key and never
|
||||||
if (!session?.user?.id) {
|
// manage one themselves. GET matters as much as POST - it returns the masked key.
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
const session = await requireOwner()
|
||||||
}
|
if (session instanceof NextResponse) return session
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const provider = searchParams.get("provider")
|
const provider = searchParams.get("provider")
|
||||||
|
|||||||
426
src/components/settings/settings-client.tsx
Normal file
426
src/components/settings/settings-client.tsx
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Header } from "@/components/layout/header"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Key, Trash2, Check, Loader2, Shield, Sliders } from "lucide-react"
|
||||||
|
import { BackupRestore } from "@/components/settings/backup-restore"
|
||||||
|
|
||||||
|
interface ApiKeyInfo {
|
||||||
|
id: string
|
||||||
|
provider: string
|
||||||
|
label?: string
|
||||||
|
maskedKey: string
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKeysResponse {
|
||||||
|
keys: ApiKeyInfo[]
|
||||||
|
gatewayUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsClient({ isOwner }: { isOwner: boolean }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
const { data: apiKeyData } = useQuery<ApiKeysResponse>({
|
||||||
|
queryKey: ["api-keys"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/settings/api-keys")
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch API keys")
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
enabled: isOwner,
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiKeys = apiKeyData?.keys ?? []
|
||||||
|
const legacyKeys = apiKeys.filter(
|
||||||
|
(k) => k.provider === "claude" || k.provider === "openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preferences
|
||||||
|
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
||||||
|
queryKey: ["preferences"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/settings/preferences")
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch preferences")
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const savePreferences = useMutation({
|
||||||
|
mutationFn: async (prefs: Record<string, unknown>) => {
|
||||||
|
const res = await fetch("/api/settings/preferences", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(prefs),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed to save preferences")
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["preferences"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header title="Settings" />
|
||||||
|
<div className="p-4 md:p-8 max-w-2xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your AI providers and preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Keys Section - owner only. Members use a gateway key minted for
|
||||||
|
them with a daily budget, and never manage one. The api-keys route
|
||||||
|
enforces this server-side; hiding it here is just presentation. */}
|
||||||
|
{isOwner && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Key className="h-5 w-5" />
|
||||||
|
AI Gateway
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
AI features route through Switchboard, which picks the best model for each
|
||||||
|
request. Add your gateway API key below — it is encrypted before storage.
|
||||||
|
{apiKeyData?.gatewayUrl && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
This app is pointed at{" "}
|
||||||
|
<code className="text-xs">{apiKeyData.gatewayUrl}</code>.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ApiKeyForm
|
||||||
|
provider="switchboard"
|
||||||
|
label="Switchboard Gateway"
|
||||||
|
existingKey={apiKeys.find((k) => k.provider === "switchboard")}
|
||||||
|
/>
|
||||||
|
{legacyKeys.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<LegacyKeyNotice keys={legacyKeys} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preferences Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sliders className="h-5 w-5" />
|
||||||
|
Drink Preferences
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Help the AI make better recommendations by telling it what you like
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PreferencesForm
|
||||||
|
preferences={preferences}
|
||||||
|
isLoading={prefsLoading}
|
||||||
|
onSave={(prefs) => savePreferences.mutate(prefs)}
|
||||||
|
isSaving={savePreferences.isPending}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Backup & Restore Section */}
|
||||||
|
<BackupRestore />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
claude: "Anthropic Claude",
|
||||||
|
openai: "OpenAI",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys left over from when the app called Claude and OpenAI directly. They are
|
||||||
|
* already ignored when picking a provider, but they are shown here so a user who
|
||||||
|
* still has one can see it is inert and remove it.
|
||||||
|
*/
|
||||||
|
function LegacyKeyNotice({ keys }: { keys: ApiKeyInfo[] }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const deleteKey = useMutation({
|
||||||
|
mutationFn: async (provider: string) => {
|
||||||
|
const res = await fetch(`/api/settings/api-keys?provider=${provider}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed to delete API key")
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed p-3 space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
These keys are from an earlier version that called each AI provider directly.
|
||||||
|
They are no longer used and can be removed.
|
||||||
|
</p>
|
||||||
|
{keys.map((key) => (
|
||||||
|
<div key={key.id} className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{LEGACY_PROVIDER_LABELS[key.provider] ?? key.provider}
|
||||||
|
</p>
|
||||||
|
<code className="text-xs bg-muted px-2 py-0.5 rounded">
|
||||||
|
{key.maskedKey}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteKey.mutate(key.provider)}
|
||||||
|
disabled={deleteKey.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1 text-destructive" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiKeyForm({
|
||||||
|
provider,
|
||||||
|
label,
|
||||||
|
existingKey,
|
||||||
|
}: {
|
||||||
|
provider: string
|
||||||
|
label: string
|
||||||
|
existingKey?: ApiKeyInfo
|
||||||
|
}) {
|
||||||
|
const [apiKey, setApiKey] = useState("")
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const saveKey = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await fetch("/api/settings/api-keys", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ provider, apiKey }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed to save API key")
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
|
||||||
|
setApiKey("")
|
||||||
|
setIsEditing(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteKey = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await fetch(`/api/settings/api-keys?provider=${provider}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed to delete API key")
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["api-keys"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingKey && !isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{label}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<code className="text-sm bg-muted px-2 py-0.5 rounded">
|
||||||
|
{existingKey.maskedKey}
|
||||||
|
</code>
|
||||||
|
<Badge variant="outline" className="text-xs text-green-600">
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteKey.mutate()}
|
||||||
|
disabled={deleteKey.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="font-medium">{label}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={`Enter your ${label} API key`}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveKey.mutate()}
|
||||||
|
disabled={!apiKey || saveKey.isPending}
|
||||||
|
>
|
||||||
|
{saveKey.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isEditing && (
|
||||||
|
<Button variant="ghost" onClick={() => setIsEditing(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
Your key is encrypted before storage and never exposed in full
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreferencesForm({
|
||||||
|
preferences,
|
||||||
|
isLoading,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: {
|
||||||
|
preferences: Record<string, unknown> | undefined
|
||||||
|
isLoading: boolean
|
||||||
|
onSave: (prefs: Record<string, unknown>) => void
|
||||||
|
isSaving: boolean
|
||||||
|
}) {
|
||||||
|
const [preferredStyles, setPreferredStyles] = useState("")
|
||||||
|
const [avoidedStyles, setAvoidedStyles] = useState("")
|
||||||
|
const [minAbv, setMinAbv] = useState("")
|
||||||
|
const [maxAbv, setMaxAbv] = useState("")
|
||||||
|
const [initialized, setInitialized] = useState(false)
|
||||||
|
|
||||||
|
if (preferences && !initialized) {
|
||||||
|
const prefs = preferences as { preferredStyles?: string[]; avoidedStyles?: string[]; minAbv?: number; maxAbv?: number }
|
||||||
|
setPreferredStyles(prefs.preferredStyles?.join(", ") || "")
|
||||||
|
setAvoidedStyles(prefs.avoidedStyles?.join(", ") || "")
|
||||||
|
setMinAbv(prefs.minAbv?.toString() || "")
|
||||||
|
setMaxAbv(prefs.maxAbv?.toString() || "")
|
||||||
|
setInitialized(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSave({
|
||||||
|
preferredStyles: preferredStyles
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
avoidedStyles: avoidedStyles
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
minAbv: minAbv ? parseFloat(minAbv) : null,
|
||||||
|
maxAbv: maxAbv ? parseFloat(maxAbv) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <div className="space-y-3"><p className="text-sm text-muted-foreground">Loading...</p></div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="preferred">Preferred Styles</Label>
|
||||||
|
<Input
|
||||||
|
id="preferred"
|
||||||
|
placeholder="e.g., IPA, Stout, Pinot Noir, Malbec"
|
||||||
|
value={preferredStyles}
|
||||||
|
onChange={(e) => setPreferredStyles(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Comma-separated list of styles you enjoy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="avoided">Avoided Styles</Label>
|
||||||
|
<Input
|
||||||
|
id="avoided"
|
||||||
|
placeholder="e.g., Sour, Light Lager, Rosé"
|
||||||
|
value={avoidedStyles}
|
||||||
|
onChange={(e) => setAvoidedStyles(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Comma-separated list of styles you want to avoid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="minAbv">Min ABV %</Label>
|
||||||
|
<Input
|
||||||
|
id="minAbv"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
placeholder="e.g., 4.0"
|
||||||
|
value={minAbv}
|
||||||
|
onChange={(e) => setMinAbv(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="maxAbv">Max ABV %</Label>
|
||||||
|
<Input
|
||||||
|
id="maxAbv"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
placeholder="e.g., 12.0"
|
||||||
|
value={maxAbv}
|
||||||
|
onChange={(e) => setMaxAbv(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save Preferences"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { AIProvider } from "./types"
|
import type { AIProvider } from "./types"
|
||||||
import { SwitchboardProvider } from "./switchboard-provider"
|
import { SwitchboardProvider } from "./switchboard-provider"
|
||||||
import { AIGatewayError } from "./errors"
|
import { AIGatewayError } from "./errors"
|
||||||
|
import { mintMemberKey, ownerGatewayKey } from "./switchboard-keys"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { decrypt, encrypt } from "@/lib/encryption"
|
||||||
|
|
||||||
/** The only provider this app uses. Also the `provider` value stored on UserApiKey. */
|
/** The only provider this app uses. Also the `provider` value stored on UserApiKey. */
|
||||||
export const AI_PROVIDER = "switchboard" as const
|
export const AI_PROVIDER = "switchboard" as const
|
||||||
@@ -10,6 +11,9 @@ export const AI_PROVIDER = "switchboard" as const
|
|||||||
export const NO_KEY_MESSAGE =
|
export const NO_KEY_MESSAGE =
|
||||||
"No Switchboard API key configured. Add one in Settings."
|
"No Switchboard API key configured. Add one in Settings."
|
||||||
|
|
||||||
|
export const AI_DISABLED_MESSAGE =
|
||||||
|
"AI features have been turned off for your account. Contact the owner."
|
||||||
|
|
||||||
export function createProvider(providerName: string, apiKey: string): AIProvider {
|
export function createProvider(providerName: string, apiKey: string): AIProvider {
|
||||||
if (providerName === AI_PROVIDER) return new SwitchboardProvider(apiKey)
|
if (providerName === AI_PROVIDER) return new SwitchboardProvider(apiKey)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -18,24 +22,75 @@ export function createProvider(providerName: string, apiKey: string): AIProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source of truth for "give me this user's configured AI provider".
|
* Resolve the gateway key to use for a request, in order:
|
||||||
*
|
*
|
||||||
* Filtering on `provider` is what makes the migration from the old direct
|
* 1. The user's own active key. Unchanged for the owner, and minted member keys
|
||||||
* Claude/OpenAI integration safe: a leftover "claude" or "openai" row holds a vendor
|
* land here too, so the encryption path and every call site stay the same.
|
||||||
* key that the gateway would reject, so those rows are ignored entirely and the user
|
* 2. Mint one for a member, capped to their daily budget. Lazy and idempotent -
|
||||||
* gets "add a key in Settings" rather than a confusing auth failure.
|
* the unique constraint on (userId, provider) makes the upsert safe.
|
||||||
|
* 3. The owner's key, for this one request, if minting is unavailable. Better a
|
||||||
|
* served request than a hard failure when the gateway is briefly down.
|
||||||
|
*
|
||||||
|
* Filtering on `provider` also ignores legacy claude/openai rows, which hold vendor
|
||||||
|
* keys the gateway would reject.
|
||||||
*/
|
*/
|
||||||
export async function getUserProvider(
|
async function resolveApiKey(userId: string): Promise<string> {
|
||||||
userId: string
|
const user = await prisma.user.findUnique({
|
||||||
): Promise<SwitchboardProvider> {
|
where: { id: userId },
|
||||||
const record = await prisma.userApiKey.findFirst({
|
select: { role: true, aiEnabled: true, aiDailyBudgetUsd: true },
|
||||||
|
})
|
||||||
|
if (!user) throw new AIGatewayError("no such user", 401, "Unauthorized", "no_key")
|
||||||
|
|
||||||
|
if (!user.aiEnabled) {
|
||||||
|
throw new AIGatewayError("ai disabled", 403, AI_DISABLED_MESSAGE, "no_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.userApiKey.findFirst({
|
||||||
where: { userId, isActive: true, provider: AI_PROVIDER },
|
where: { userId, isActive: true, provider: AI_PROVIDER },
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
})
|
})
|
||||||
|
if (existing) return decrypt(existing.encryptedKey, existing.iv)
|
||||||
|
|
||||||
if (!record) {
|
// The owner is expected to supply their own key in Settings; minting one for them
|
||||||
|
// from their own owner key would be circular.
|
||||||
|
if (user.role === "OWNER") {
|
||||||
throw new AIGatewayError("no api key", 400, NO_KEY_MESSAGE, "no_key")
|
throw new AIGatewayError("no api key", 400, NO_KEY_MESSAGE, "no_key")
|
||||||
}
|
}
|
||||||
|
|
||||||
return new SwitchboardProvider(decrypt(record.encryptedKey, record.iv))
|
const minted = await mintMemberKey(userId, user.aiDailyBudgetUsd)
|
||||||
|
if (minted) {
|
||||||
|
const { encrypted, iv } = encrypt(minted.key)
|
||||||
|
await prisma.userApiKey.upsert({
|
||||||
|
where: { userId_provider: { userId, provider: AI_PROVIDER } },
|
||||||
|
update: { encryptedKey: encrypted, iv, gatewayKeyId: minted.id, isActive: true },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
provider: AI_PROVIDER,
|
||||||
|
encryptedKey: encrypted,
|
||||||
|
iv,
|
||||||
|
gatewayKeyId: minted.id,
|
||||||
|
label: "auto",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return minted.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minting failed. Borrow the owner's key so the feature still works, and make it
|
||||||
|
// loud - a member on the owner's key has no per-user budget cap.
|
||||||
|
const fallback = ownerGatewayKey()
|
||||||
|
if (fallback) {
|
||||||
|
console.warn(
|
||||||
|
`[switchboard] minting unavailable for ${userId}; using the owner key for this request (no per-user budget applies)`
|
||||||
|
)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AIGatewayError("no api key", 400, NO_KEY_MESSAGE, "no_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single source of truth for "give me this user's configured AI provider". */
|
||||||
|
export async function getUserProvider(userId: string): Promise<SwitchboardProvider> {
|
||||||
|
const apiKey = await resolveApiKey(userId)
|
||||||
|
return new SwitchboardProvider(apiKey, userId)
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/lib/ai/switchboard-keys.ts
Normal file
101
src/lib/ai/switchboard-keys.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { switchboardBaseUrl } from "./switchboard-provider"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mints per-member gateway keys.
|
||||||
|
*
|
||||||
|
* Members share the owner's AI budget, so each gets their own Switchboard key with a
|
||||||
|
* daily cap rather than everyone using one key. That gives per-user attribution and,
|
||||||
|
* more importantly, a real spend limit: the app's own rate limiter lives in memory and
|
||||||
|
* resets on every deploy, so it can never be a spend control. The gateway enforces
|
||||||
|
* these caps itself and answers 402 {code:"guardrail"} when one is hit, which
|
||||||
|
* toAIGatewayError already maps to a budget message.
|
||||||
|
*
|
||||||
|
* The owner key is only ever used here. Inference always uses a per-user key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Default daily cap for a member with no explicit budget set. */
|
||||||
|
export const DEFAULT_MEMBER_DAILY_USD = 1.0
|
||||||
|
|
||||||
|
/** Ceiling for any single request, so one prompt cannot spend the whole day's budget. */
|
||||||
|
export const DEFAULT_MAX_COST_PER_REQUEST_USD = 0.25
|
||||||
|
|
||||||
|
export function ownerGatewayKey(): string | null {
|
||||||
|
return process.env.SWITCHBOARD_OWNER_KEY || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MintedKey {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a gateway key for a member. Returns null if no owner key is configured or the
|
||||||
|
* gateway refuses - callers fall back rather than failing the user's request outright.
|
||||||
|
*
|
||||||
|
* Verified against the gateway: POST /v1/keys takes {label, limits:{dailyBudgetUsd,
|
||||||
|
* maxCostPerRequest}} and returns {id, key, ...}. Key management is owner-key-only;
|
||||||
|
* a managed key gets 403.
|
||||||
|
*/
|
||||||
|
export async function mintMemberKey(
|
||||||
|
userId: string,
|
||||||
|
dailyBudgetUsd: number | null
|
||||||
|
): Promise<MintedKey | null> {
|
||||||
|
const owner = ownerGatewayKey()
|
||||||
|
if (!owner) {
|
||||||
|
console.warn("[switchboard] SWITCHBOARD_OWNER_KEY not set - cannot mint member keys")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${switchboardBaseUrl()}/keys`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${owner}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
// Label carries the user id so a key can be traced back from the gateway UI.
|
||||||
|
label: `drinktracker:${userId}`,
|
||||||
|
limits: {
|
||||||
|
dailyBudgetUsd: dailyBudgetUsd ?? DEFAULT_MEMBER_DAILY_USD,
|
||||||
|
maxCostPerRequest: DEFAULT_MAX_COST_PER_REQUEST_USD,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`[switchboard] mint failed for ${userId}: HTTP ${response.status}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as { id?: string; key?: string }
|
||||||
|
if (!body.key || !body.id) {
|
||||||
|
console.warn(`[switchboard] mint returned no key for ${userId}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[switchboard] minted gateway key ${body.id} for user ${userId}`)
|
||||||
|
return { id: body.id, key: body.key }
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[switchboard] mint threw for ${userId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort revocation, used when an account is deleted. */
|
||||||
|
export async function revokeGatewayKey(keyId: string): Promise<boolean> {
|
||||||
|
const owner = ownerGatewayKey()
|
||||||
|
if (!owner) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${switchboardBaseUrl()}/keys/${keyId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${owner}` },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
})
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,33 @@
|
|||||||
import type { SwitchboardMeta } from "./switchboard-types"
|
import type { SwitchboardMeta } from "./switchboard-types"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist one row per AI call so member spend is queryable.
|
||||||
|
*
|
||||||
|
* Deliberately not awaited by callers: an insert failure must never turn a working
|
||||||
|
* AI response into an error for the user. Failures are logged and dropped.
|
||||||
|
*/
|
||||||
|
export function recordAiCall(
|
||||||
|
userId: string,
|
||||||
|
feature: string,
|
||||||
|
meta: SwitchboardMeta | null
|
||||||
|
): void {
|
||||||
|
void prisma.aiCall
|
||||||
|
.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
feature,
|
||||||
|
modelId: meta?.model_id ?? null,
|
||||||
|
provider: meta?.provider ?? null,
|
||||||
|
costUsd: meta?.cost_usd ?? null,
|
||||||
|
latencyMs: meta?.latency_ms ?? null,
|
||||||
|
failover: meta?.failover ?? false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("[switchboard] failed to record ai call:", error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One line per gateway call so the cost and the model actually used are visible in
|
* One line per gateway call so the cost and the model actually used are visible in
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type FeatureRouting,
|
type FeatureRouting,
|
||||||
type SwitchboardMeta,
|
type SwitchboardMeta,
|
||||||
} from "./switchboard-types"
|
} from "./switchboard-types"
|
||||||
import { logSwitchboardMeta } from "./switchboard-log"
|
import { logSwitchboardMeta, recordAiCall } from "./switchboard-log"
|
||||||
|
|
||||||
type ChatParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming
|
type ChatParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming
|
||||||
type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion
|
type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion
|
||||||
@@ -41,9 +41,12 @@ export class SwitchboardProvider extends BaseAIProvider {
|
|||||||
lastMeta: SwitchboardMeta | null = null
|
lastMeta: SwitchboardMeta | null = null
|
||||||
|
|
||||||
private client: OpenAI
|
private client: OpenAI
|
||||||
|
/** Set when the provider was built for a known user, so calls can be attributed. */
|
||||||
|
private userId?: string
|
||||||
|
|
||||||
constructor(apiKey: string) {
|
constructor(apiKey: string, userId?: string) {
|
||||||
super()
|
super()
|
||||||
|
this.userId = userId
|
||||||
this.client = new OpenAI({
|
this.client = new OpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL: switchboardBaseUrl(),
|
baseURL: switchboardBaseUrl(),
|
||||||
@@ -124,6 +127,10 @@ export class SwitchboardProvider extends BaseAIProvider {
|
|||||||
|
|
||||||
this.lastMeta = readSwitchboardMeta(completion)
|
this.lastMeta = readSwitchboardMeta(completion)
|
||||||
logSwitchboardMeta(feature, this.lastMeta)
|
logSwitchboardMeta(feature, this.lastMeta)
|
||||||
|
// Fire and forget: spend tracking must never fail a user's request.
|
||||||
|
if (this.userId) {
|
||||||
|
recordAiCall(this.userId, feature, this.lastMeta)
|
||||||
|
}
|
||||||
|
|
||||||
const message = completion.choices?.[0]?.message?.content
|
const message = completion.choices?.[0]?.message?.content
|
||||||
if (!message) {
|
if (!message) {
|
||||||
|
|||||||
Reference in New Issue
Block a user