Files
drinktracker/src/app/api/bartender/suggest/route.ts
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

94 lines
3.1 KiB
TypeScript

import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
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"
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { success: withinLimit } = rateLimit(`bartender-suggest:${session.user.id}`, 5, 60000)
if (!withinLimit) {
return NextResponse.json(
{ error: "Too many requests. Please wait a moment." },
{ status: 429 }
)
}
try {
const barItems = await prisma.barItem.findMany({
where: {
userId: session.user.id,
quantity: { not: "EMPTY" },
},
select: { name: true, category: true, quantity: true },
})
if (barItems.length === 0) {
return NextResponse.json(
{ error: "No items in your bar inventory. Add items to your bar first." },
{ status: 400 }
)
}
const inventoryString = buildBarInventoryString(barItems)
const prompt = WHAT_CAN_I_MAKE_PROMPT.replace("{barInventory}", inventoryString)
const provider = await getUserProvider(session.user.id)
const rawResponse = await provider.sendTextRequest(
prompt,
"What cocktails can I make with my bar inventory?",
FEATURE_ROUTING.bartenderSuggest
)
// Parse JSON from response
let suggestions
try {
suggestions = JSON.parse(rawResponse)
} catch {
const codeBlockMatch = rawResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (codeBlockMatch) {
suggestions = JSON.parse(codeBlockMatch[1].trim())
} else {
const arrayMatch = rawResponse.match(/\[[\s\S]*\]/)
if (arrayMatch) {
suggestions = JSON.parse(arrayMatch[0])
} else {
throw new Error("Could not parse suggestions from AI response")
}
}
}
if (!Array.isArray(suggestions)) {
suggestions = []
}
// Post-process: fuzzy-match ingredients against bar inventory and re-sort
if (barItems.length > 0) {
suggestions = suggestions.map((s: { ingredients?: { name: string; amount: string; available: boolean }[]; missingCount?: number }) => {
if (s.ingredients && Array.isArray(s.ingredients)) {
s.ingredients = fuzzyMatchIngredients(s.ingredients, barItems)
s.missingCount = recalculateMissingCount(s.ingredients)
}
return s
})
// Re-sort by missingCount ascending
suggestions.sort((a: { missingCount?: number }, b: { missingCount?: number }) =>
(a.missingCount ?? 99) - (b.missingCount ?? 99)
)
}
return NextResponse.json({ suggestions })
} catch (error) {
return aiErrorResponse(error, "Failed to generate suggestions. Please try again.")
}
}