Files
drinktracker/src/app/(app)/settings/page.tsx
JP a0e1619072 Route all AI features through the Switchboard gateway
Replace the direct Anthropic and OpenAI integrations with a single
provider that talks to Switchboard, an OpenAI-compatible gateway that
routes each request to the best available model. The app no longer pins
a model id anywhere: it sends switchboard/auto and lets the gateway
choose, then logs which model answered and what it cost.

Routing levers are set per feature in src/lib/ai/routing.ts. Three of
those choices came from measuring against the live gateway:

- category and prefer_free are set explicitly on every request. An API
  key carries its own routing defaults, and anything left unset inherits
  them - drink prompts were being sent to a free coding model.
- Token budgets are generous because the router may pick a reasoning
  model, and reasoning tokens come out of the same max_tokens budget as
  the answer. At 512 tokens a request returned null content; at 4096 the
  same request returned correct JSON.
- No tier lever on text features. tier "cheap" pinned a slow reasoning
  model (42-180s, two timeouts and one truncated response in five
  trials) and tier "frontier" escalated as far as Opus at $0.02 a call,
  while unconstrained routing answered in about a second. Vision keeps
  "frontier", where the accuracy is worth a few tenths of a cent.

Gateway failures are mapped to actionable messages rather than passed
through: a 401 relayed as 401 would read as an expired session and
bounce the user to login, and a 429 would collide with the app's own
rate limiter.

Also collapses the key lookup that was duplicated across ten call sites
into getUserProvider(), which fixes a latent bug where a bare findFirst
with no ordering let different features pick different providers.

Existing claude/openai key rows are ignored at runtime and offered for
removal in Settings, so no migration is needed before deploying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 16:41:00 +00:00

422 lines
12 KiB
TypeScript

"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",
}
/**
* 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&eacute;"
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>
)
}