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>
This commit is contained in:
JP
2026-08-08 16:41:00 +00:00
parent 7c41b15ecc
commit a0e1619072
31 changed files with 830 additions and 429 deletions

View File

@@ -20,11 +20,16 @@ interface ApiKeyInfo {
isActive: boolean
}
interface ApiKeysResponse {
keys: ApiKeyInfo[]
gatewayUrl: string
}
export default function SettingsPage() {
const queryClient = useQueryClient()
// API Keys
const { data: apiKeys = [] } = useQuery<ApiKeyInfo[]>({
const { data: apiKeyData } = useQuery<ApiKeysResponse>({
queryKey: ["api-keys"],
queryFn: async () => {
const res = await fetch("/api/settings/api-keys")
@@ -33,6 +38,11 @@ export default function SettingsPage() {
},
})
const apiKeys = apiKeyData?.keys ?? []
const legacyKeys = apiKeys.filter(
(k) => k.provider === "claude" || k.provider === "openai"
)
// Preferences
const { data: preferences, isLoading: prefsLoading } = useQuery({
queryKey: ["preferences"],
@@ -74,16 +84,32 @@ export default function SettingsPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
AI Provider Keys
AI Gateway
</CardTitle>
<CardDescription>
Add your API keys for AI-powered menu scanning. Keys are encrypted and stored securely.
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="claude" label="Anthropic Claude" existingKey={apiKeys.find(k => k.provider === "claude")} />
<Separator />
<ApiKeyForm provider="openai" label="OpenAI GPT-4o" existingKey={apiKeys.find(k => k.provider === "openai")} />
<ApiKeyForm
provider="switchboard"
label="Switchboard Gateway"
existingKey={apiKeys.find((k) => k.provider === "switchboard")}
/>
{legacyKeys.length > 0 && (
<>
<Separator />
<LegacyKeyNotice keys={legacyKeys} />
</>
)}
</CardContent>
</Card>
@@ -115,6 +141,62 @@ export default function SettingsPage() {
)
}
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,

View File

@@ -1,8 +1,7 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "@/lib/ai/provider-factory"
import { getUserProvider } from "@/lib/ai/provider-factory"
import { aiErrorResponse } from "@/lib/ai/errors"
import { rateLimit } from "@/lib/rate-limit"
import { z } from "zod"
@@ -73,19 +72,7 @@ export async function POST(request: Request) {
const { imageBase64, mimeType, context } = parsed.data
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId: session.user.id, isActive: true },
})
if (!apiKeyRecord) {
return NextResponse.json(
{ error: "No AI provider configured. Add an API key in Settings." },
{ status: 400 }
)
}
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const provider = await getUserProvider(session.user.id)
const result = await provider.extractLabel(imageBase64, mimeType)
@@ -107,10 +94,6 @@ export async function POST(request: Request) {
return NextResponse.json(response)
} catch (error) {
console.error("AI identify error:", error)
return NextResponse.json(
{ error: "Failed to identify product. Please try again." },
{ status: 500 }
)
return aiErrorResponse(error, "Failed to identify product. Please try again.")
}
}

View File

@@ -1,8 +1,8 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "@/lib/ai/provider-factory"
import { AI_PROVIDER, getUserProvider } from "@/lib/ai/provider-factory"
import { aiErrorResponse } from "@/lib/ai/errors"
import { rateLimit } from "@/lib/rate-limit"
import { z } from "zod"
import type { Prisma } from "@prisma/client"
@@ -33,18 +33,6 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid query" }, { status: 400 })
}
// Get user's AI provider
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId: session.user.id, isActive: true },
})
if (!apiKeyRecord) {
return NextResponse.json(
{ error: "No AI provider configured. Add an API key in Settings." },
{ status: 400 }
)
}
// Check cache first (24hr TTL)
const queryHash = parsed.data.query.toLowerCase().trim()
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
@@ -54,7 +42,7 @@ export async function POST(request: Request) {
userId_queryHash_provider: {
userId: session.user.id,
queryHash,
provider: apiKeyRecord.provider,
provider: AI_PROVIDER,
},
},
})
@@ -63,8 +51,7 @@ export async function POST(request: Request) {
return NextResponse.json(cached.results)
}
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const provider = await getUserProvider(session.user.id)
const result = await provider.searchDrinks(parsed.data.query)
@@ -74,7 +61,7 @@ export async function POST(request: Request) {
userId_queryHash_provider: {
userId: session.user.id,
queryHash,
provider: apiKeyRecord.provider,
provider: AI_PROVIDER,
},
},
update: {
@@ -87,16 +74,12 @@ export async function POST(request: Request) {
queryHash,
query: parsed.data.query,
results: { drinks: result.drinks } as unknown as Prisma.InputJsonValue,
provider: apiKeyRecord.provider,
provider: AI_PROVIDER,
},
})
return NextResponse.json({ drinks: result.drinks })
} catch (error) {
console.error("AI search error:", error)
return NextResponse.json(
{ error: "Search failed. Please try again." },
{ status: 500 }
)
return aiErrorResponse(error, "Search failed. Please try again.")
}
}

View File

@@ -1,8 +1,8 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "@/lib/ai/provider-factory"
import { getUserProvider } from "@/lib/ai/provider-factory"
import { FEATURE_ROUTING } from "@/lib/ai/routing"
import { rateLimit } from "@/lib/rate-limit"
import { z } from "zod"
@@ -71,13 +71,7 @@ async function lookupOpenFoodFacts(barcode: string) {
async function lookupViaAI(barcode: string, userId: string) {
try {
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId, isActive: true },
})
if (!apiKeyRecord) return null
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const provider = await getUserProvider(userId)
const systemPrompt = `You are a product identification expert. Given a UPC/EAN barcode number, identify the product — especially alcoholic beverages, spirits, mixers, and bar supplies.
@@ -91,7 +85,8 @@ Do not include any text before or after the JSON.`
const response = await provider.sendTextRequest(
systemPrompt,
`Identify the product with UPC/EAN barcode: ${barcode}`
`Identify the product with UPC/EAN barcode: ${barcode}`,
FEATURE_ROUTING.barcodeLookup
)
const match = response.match(/\{[\s\S]*\}/)
@@ -104,7 +99,10 @@ Do not include any text before or after the JSON.`
brand: (parsed.brand as string) || null,
category: parsed.category || "SPIRITS",
}
} catch {
} catch (error) {
// Best-effort fallback after Open Food Facts, so a failure here is not fatal to
// the request. Logged so a gateway outage is not completely invisible.
console.warn("[switchboard] barcode AI fallback failed:", error)
return null
}
}

View File

@@ -1,8 +1,9 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "@/lib/ai/provider-factory"
import { getUserProvider } from "@/lib/ai/provider-factory"
import { FEATURE_ROUTING } from "@/lib/ai/routing"
import { aiErrorResponse } from "@/lib/ai/errors"
import { rateLimit } from "@/lib/rate-limit"
import { COCKTAIL_RECIPE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts"
import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher"
@@ -34,17 +35,6 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
}
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId: session.user.id, isActive: true },
})
if (!apiKeyRecord) {
return NextResponse.json(
{ error: "No AI provider configured. Add an API key in Settings." },
{ status: 400 }
)
}
const barItems = await prisma.barItem.findMany({
where: {
userId: session.user.id,
@@ -56,12 +46,12 @@ export async function POST(request: Request) {
const inventoryString = buildBarInventoryString(barItems)
const prompt = COCKTAIL_RECIPE_PROMPT.replace("{barInventory}", inventoryString)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const provider = await getUserProvider(session.user.id)
const rawResponse = await provider.sendTextRequest(
prompt,
`Generate a recipe for: ${parsed.data.cocktailName}`
`Generate a recipe for: ${parsed.data.cocktailName}`,
FEATURE_ROUTING.bartenderRecreate
)
// Parse JSON from response
@@ -91,10 +81,6 @@ export async function POST(request: Request) {
return NextResponse.json(recipe)
} catch (error) {
console.error("Bartender recreate error:", error)
return NextResponse.json(
{ error: "Failed to generate recipe. Please try again." },
{ status: 500 }
)
return aiErrorResponse(error, "Failed to generate recipe. Please try again.")
}
}

View File

@@ -1,8 +1,9 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "@/lib/ai/provider-factory"
import { getUserProvider } from "@/lib/ai/provider-factory"
import { FEATURE_ROUTING } from "@/lib/ai/routing"
import { aiErrorResponse } from "@/lib/ai/errors"
import { rateLimit } from "@/lib/rate-limit"
import { WHAT_CAN_I_MAKE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts"
import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher"
@@ -22,17 +23,6 @@ export async function POST() {
}
try {
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId: session.user.id, isActive: true },
})
if (!apiKeyRecord) {
return NextResponse.json(
{ error: "No AI provider configured. Add an API key in Settings." },
{ status: 400 }
)
}
const barItems = await prisma.barItem.findMany({
where: {
userId: session.user.id,
@@ -51,12 +41,12 @@ export async function POST() {
const inventoryString = buildBarInventoryString(barItems)
const prompt = WHAT_CAN_I_MAKE_PROMPT.replace("{barInventory}", inventoryString)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const provider = await getUserProvider(session.user.id)
const rawResponse = await provider.sendTextRequest(
prompt,
"What cocktails can I make with my bar inventory?"
"What cocktails can I make with my bar inventory?",
FEATURE_ROUTING.bartenderSuggest
)
// Parse JSON from response
@@ -98,10 +88,6 @@ export async function POST() {
return NextResponse.json({ suggestions })
} catch (error) {
console.error("Bartender suggest error:", error)
return NextResponse.json(
{ error: "Failed to generate suggestions. Please try again." },
{ status: 500 }
)
return aiErrorResponse(error, "Failed to generate suggestions. Please try again.")
}
}

View File

@@ -1,8 +1,9 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "@/lib/ai/provider-factory"
import { getUserProvider } from "@/lib/ai/provider-factory"
import { FEATURE_ROUTING } from "@/lib/ai/routing"
import { aiErrorResponse } from "@/lib/ai/errors"
import { rateLimit } from "@/lib/rate-limit"
import { FLAVOR_PROFILE_PROMPT, buildDrinkHistoryString } from "@/lib/ai/prompts"
import type { Prisma } from "@prisma/client"
@@ -67,18 +68,6 @@ export async function POST() {
}
try {
// Get user's AI provider
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId: session.user.id, isActive: true },
})
if (!apiKeyRecord) {
return NextResponse.json(
{ error: "No AI provider configured. Add an API key in Settings." },
{ status: 400 }
)
}
// Fetch all drinks with ratings
const drinks = await prisma.drink.findMany({
where: { userId: session.user.id },
@@ -120,12 +109,12 @@ export async function POST() {
const drinkHistory = buildDrinkHistoryString(drinkSummaries)
const prompt = FLAVOR_PROFILE_PROMPT.replace("{drinkHistory}", drinkHistory)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const provider = await getUserProvider(session.user.id)
const rawResponse = await provider.sendTextRequest(
prompt,
"Analyze my drink history and build my flavor profile."
"Analyze my drink history and build my flavor profile.",
FEATURE_ROUTING.flavorProfile
)
// Parse the JSON response
@@ -183,10 +172,6 @@ export async function POST() {
},
})
} catch (error) {
console.error("Flavor profile generation error:", error)
return NextResponse.json(
{ error: "Failed to generate flavor profile. Please try again." },
{ status: 500 }
)
return aiErrorResponse(error, "Failed to generate flavor profile. Please try again.")
}
}

View File

@@ -1,8 +1,9 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "@/lib/ai/provider-factory"
import { getUserProvider } from "@/lib/ai/provider-factory"
import { FEATURE_ROUTING } from "@/lib/ai/routing"
import { aiErrorResponse } from "@/lib/ai/errors"
import { rateLimit } from "@/lib/rate-limit"
import { SIMILAR_DRINK_PROMPT } from "@/lib/ai/prompts"
import { z } from "zod"
@@ -40,18 +41,6 @@ export async function POST(request: Request) {
)
}
// Get user's AI provider
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId: session.user.id, isActive: true },
})
if (!apiKeyRecord) {
return NextResponse.json(
{ error: "No AI provider configured. Add an API key in Settings." },
{ status: 400 }
)
}
// Fetch the source drink
const drink = await prisma.drink.findFirst({
where: { id: parsed.data.drinkId, userId: session.user.id },
@@ -99,12 +88,12 @@ export async function POST(request: Request) {
.replace("{sourceDrink}", sourceDrink)
.replace("{flavorProfile}", flavorProfile)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const provider = await getUserProvider(session.user.id)
const rawResponse = await provider.sendTextRequest(
prompt,
`Find drinks similar to ${drink.name}.`
`Find drinks similar to ${drink.name}.`,
FEATURE_ROUTING.recommendSimilar
)
// Parse JSON response
@@ -131,10 +120,6 @@ export async function POST(request: Request) {
return NextResponse.json({ recommendations, sourceDrink: drink.name })
} catch (error) {
console.error("Similar drink error:", error)
return NextResponse.json(
{ error: "Failed to find similar drinks. Please try again." },
{ status: 500 }
)
return aiErrorResponse(error, "Failed to find similar drinks. Please try again.")
}
}

View File

@@ -1,8 +1,9 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "@/lib/ai/provider-factory"
import { getUserProvider } from "@/lib/ai/provider-factory"
import { FEATURE_ROUTING } from "@/lib/ai/routing"
import { aiErrorResponse } from "@/lib/ai/errors"
import { rateLimit } from "@/lib/rate-limit"
import { RECOMMEND_DRINK_PROMPT } from "@/lib/ai/prompts"
import { z } from "zod"
@@ -38,18 +39,6 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
}
// Get user's AI provider
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId: session.user.id, isActive: true },
})
if (!apiKeyRecord) {
return NextResponse.json(
{ error: "No AI provider configured. Add an API key in Settings." },
{ status: 400 }
)
}
// Fetch flavor profile
const profile = await prisma.flavorProfile.findUnique({
where: { userId: session.user.id },
@@ -82,12 +71,12 @@ export async function POST(request: Request) {
.replace("{flavorProfile}", profile.profileText)
.replace("{context}", context)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const provider = await getUserProvider(session.user.id)
const rawResponse = await provider.sendTextRequest(
prompt,
"Recommend drinks for me based on my profile and the context provided."
"Recommend drinks for me based on my profile and the context provided.",
FEATURE_ROUTING.recommendSuggest
)
// Parse JSON response
@@ -114,10 +103,6 @@ export async function POST(request: Request) {
return NextResponse.json({ recommendations })
} catch (error) {
console.error("Drink suggestion error:", error)
return NextResponse.json(
{ error: "Failed to get suggestions. Please try again." },
{ status: 500 }
)
return aiErrorResponse(error, "Failed to get suggestions. Please try again.")
}
}

View File

@@ -166,12 +166,14 @@ async function processMenuScan(
])
} catch (error) {
console.error("Menu scan processing failed:", error)
// This runs detached from the request, so the stored message is all the user
// ever sees. Map gateway faults to something actionable rather than a raw error.
const { toAIGatewayError } = await import("@/lib/ai/errors")
await prisma.menuScan.update({
where: { id: scanId },
data: {
status: "FAILED",
errorMessage:
error instanceof Error ? error.message : "Unknown error",
errorMessage: toAIGatewayError(error).userMessage,
},
})
}

View File

@@ -3,6 +3,7 @@ import { auth } from "@/lib/auth"
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()
@@ -44,7 +45,9 @@ export async function GET() {
}
})
return NextResponse.json(maskedKeys)
// The gateway endpoint is server config, so surface it here rather than making the
// user guess which Switchboard instance this deployment points at.
return NextResponse.json({ keys: maskedKeys, gatewayUrl: switchboardBaseUrl() })
}
export async function POST(request: Request) {
@@ -90,6 +93,15 @@ export async function POST(request: Request) {
},
})
// Keys from the old direct Claude/OpenAI integration are already ignored when
// selecting a provider. Clear them now that a working replacement exists.
await prisma.userApiKey.deleteMany({
where: {
userId: session.user.id,
provider: { in: ["claude", "openai"] },
},
})
return NextResponse.json({
id: key.id,
provider: key.provider,