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:
@@ -1,421 +1,15 @@
|
||||
"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 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",
|
||||
}
|
||||
import { redirect } from "next/navigation"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { SettingsClient } from "@/components/settings/settings-client"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Server component so the role is known before the first render. Passing isOwner
|
||||
* down avoids the flash of an AI Gateway card that useSession() alone would give,
|
||||
* and lets the client skip the owner-only key request entirely.
|
||||
*/
|
||||
function LegacyKeyNotice({ keys }: { keys: ApiKeyInfo[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
export default async function SettingsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect("/login")
|
||||
|
||||
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>
|
||||
)
|
||||
return <SettingsClient isOwner={session.user.role === "OWNER"} />
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { requireOwner } from "@/lib/authz"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { encrypt, decrypt, maskApiKey } from "@/lib/encryption"
|
||||
import { apiKeySchema } from "@/lib/validators"
|
||||
import { switchboardBaseUrl } from "@/lib/ai/switchboard-provider"
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
// Owner only: members share the owner's gateway budget via a minted key and never
|
||||
// manage one themselves. GET matters as much as POST - it returns the masked key.
|
||||
const session = await requireOwner()
|
||||
if (session instanceof NextResponse) return session
|
||||
|
||||
const apiKeys = await prisma.userApiKey.findMany({
|
||||
where: { userId: session.user.id },
|
||||
@@ -51,10 +51,10 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
// Owner only: members share the owner's gateway budget via a minted key and never
|
||||
// manage one themselves. GET matters as much as POST - it returns the masked key.
|
||||
const session = await requireOwner()
|
||||
if (session instanceof NextResponse) return session
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -119,10 +119,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
// Owner only: members share the owner's gateway budget via a minted key and never
|
||||
// manage one themselves. GET matters as much as POST - it returns the masked key.
|
||||
const session = await requireOwner()
|
||||
if (session instanceof NextResponse) return session
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
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 { SwitchboardProvider } from "./switchboard-provider"
|
||||
import { AIGatewayError } from "./errors"
|
||||
import { mintMemberKey, ownerGatewayKey } from "./switchboard-keys"
|
||||
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. */
|
||||
export const AI_PROVIDER = "switchboard" as const
|
||||
@@ -10,6 +11,9 @@ export const AI_PROVIDER = "switchboard" as const
|
||||
export const NO_KEY_MESSAGE =
|
||||
"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 {
|
||||
if (providerName === AI_PROVIDER) return new SwitchboardProvider(apiKey)
|
||||
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
|
||||
* Claude/OpenAI integration safe: a leftover "claude" or "openai" row holds a vendor
|
||||
* key that the gateway would reject, so those rows are ignored entirely and the user
|
||||
* gets "add a key in Settings" rather than a confusing auth failure.
|
||||
* 1. The user's own active key. Unchanged for the owner, and minted member keys
|
||||
* land here too, so the encryption path and every call site stay the same.
|
||||
* 2. Mint one for a member, capped to their daily budget. Lazy and idempotent -
|
||||
* 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(
|
||||
userId: string
|
||||
): Promise<SwitchboardProvider> {
|
||||
const record = await prisma.userApiKey.findFirst({
|
||||
async function resolveApiKey(userId: string): Promise<string> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
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 },
|
||||
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")
|
||||
}
|
||||
|
||||
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 { 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
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type FeatureRouting,
|
||||
type SwitchboardMeta,
|
||||
} from "./switchboard-types"
|
||||
import { logSwitchboardMeta } from "./switchboard-log"
|
||||
import { logSwitchboardMeta, recordAiCall } from "./switchboard-log"
|
||||
|
||||
type ChatParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming
|
||||
type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion
|
||||
@@ -41,9 +41,12 @@ export class SwitchboardProvider extends BaseAIProvider {
|
||||
lastMeta: SwitchboardMeta | null = null
|
||||
|
||||
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()
|
||||
this.userId = userId
|
||||
this.client = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: switchboardBaseUrl(),
|
||||
@@ -124,6 +127,10 @@ export class SwitchboardProvider extends BaseAIProvider {
|
||||
|
||||
this.lastMeta = readSwitchboardMeta(completion)
|
||||
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
|
||||
if (!message) {
|
||||
|
||||
Reference in New Issue
Block a user