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>
422 lines
12 KiB
TypeScript
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é"
|
|
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>
|
|
)
|
|
}
|