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:
@@ -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,
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user