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>
94 lines
3.1 KiB
TypeScript
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.")
|
|
}
|
|
}
|