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,

View File

@@ -15,6 +15,8 @@ import {
DRINK_SEARCH_PROMPT,
buildRecommendationPrompt,
} from "./prompts"
import type { FeatureRouting } from "./switchboard-types"
import { FEATURE_ROUTING } from "./routing"
export abstract class BaseAIProvider implements AIProvider {
abstract name: string
@@ -22,12 +24,14 @@ export abstract class BaseAIProvider implements AIProvider {
abstract sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
mimeType: string,
routing?: FeatureRouting
): Promise<string>
abstract sendTextRequest(
systemPrompt: string,
userMessage: string
userMessage: string,
routing?: FeatureRouting
): Promise<string>
async extractMenuItems(
@@ -37,7 +41,8 @@ export abstract class BaseAIProvider implements AIProvider {
const rawResponse = await this.sendVisionRequest(
MENU_EXTRACTION_PROMPT,
imageBase64,
mimeType
mimeType,
FEATURE_ROUTING.menuExtraction
)
try {
@@ -74,7 +79,8 @@ export abstract class BaseAIProvider implements AIProvider {
const rawResponse = await this.sendTextRequest(
prompt,
"Please provide your drink recommendations based on the information above."
"Please provide your drink recommendations based on the information above.",
FEATURE_ROUTING.menuRecommend
)
try {
@@ -103,7 +109,8 @@ export abstract class BaseAIProvider implements AIProvider {
const rawResponse = await this.sendVisionRequest(
LABEL_EXTRACTION_PROMPT,
imageBase64,
mimeType
mimeType,
FEATURE_ROUTING.labelExtraction
)
try {
@@ -132,7 +139,8 @@ export abstract class BaseAIProvider implements AIProvider {
async searchDrinks(query: string): Promise<DrinkSearchResult> {
const rawResponse = await this.sendTextRequest(
DRINK_SEARCH_PROMPT,
`Search for: ${query}`
`Search for: ${query}`,
FEATURE_ROUTING.drinkSearch
)
try {

View File

@@ -1,78 +0,0 @@
import Anthropic from "@anthropic-ai/sdk"
import { BaseAIProvider } from "./base-provider"
export class ClaudeProvider extends BaseAIProvider {
name = "claude"
private client: Anthropic
constructor(apiKey: string) {
super()
this.client = new Anthropic({ apiKey })
}
async sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string> {
const response = await this.client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: systemPrompt,
messages: [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: mimeType as
| "image/jpeg"
| "image/png"
| "image/gif"
| "image/webp",
data: imageBase64,
},
},
{
type: "text",
text: "Please analyze this image and extract the information as instructed.",
},
],
},
],
})
const textBlock = response.content.find((block) => block.type === "text")
if (!textBlock || textBlock.type !== "text") {
throw new Error("No text response received from Claude")
}
return textBlock.text
}
async sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string> {
const response = await this.client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: systemPrompt,
messages: [
{
role: "user",
content: userMessage,
},
],
})
const textBlock = response.content.find((block) => block.type === "text")
if (!textBlock || textBlock.type !== "text") {
throw new Error("No text response received from Claude")
}
return textBlock.text
}
}

170
src/lib/ai/errors.ts Normal file
View File

@@ -0,0 +1,170 @@
import { NextResponse } from "next/server"
import { switchboardBaseUrl } from "./switchboard-provider"
export type AIErrorKind =
| "no_key"
| "auth"
| "budget"
| "unavailable"
| "rate_limit"
| "timeout"
| "unreachable"
| "unknown"
export class AIGatewayError extends Error {
constructor(
message: string,
readonly httpStatus: number,
readonly userMessage: string,
readonly kind: AIErrorKind
) {
super(message)
this.name = "AIGatewayError"
}
}
/** Duck-typed rather than instanceof, so this does not depend on the SDK's error exports. */
function statusOf(err: unknown): number | undefined {
if (typeof err !== "object" || err === null) return undefined
const status = (err as { status?: unknown }).status
return typeof status === "number" ? status : undefined
}
/**
* The SDK reports a timeout as APIConnectionTimeoutError, a subclass of the same
* connection error it raises when the host is unreachable, and neither carries a
* status. Matched by name so this does not depend on the SDK's error exports.
*/
function isTimeout(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false
const name = (err as { name?: unknown }).name
return typeof name === "string" && name.toLowerCase().includes("timeout")
}
/**
* These routes catch their own JSON-parsing failures alongside gateway failures, and
* a parse error has no status either. So a missing status is not enough to conclude
* the network is at fault - the error has to actually look like one.
*/
function isConnectionError(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false
const { name, code } = err as { name?: unknown; code?: unknown }
if (typeof name === "string" && name.includes("APIConnection")) return true
return (
typeof code === "string" &&
["ECONNREFUSED", "ENOTFOUND", "ECONNRESET", "EAI_AGAIN", "EHOSTUNREACH"].includes(
code
)
)
}
/**
* The gateway returns guardrail failures as `{ error, code: "guardrail" }`, but the
* body shape varies by error, so check both the top level and a nested `error` object.
*/
function isGuardrail(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false
const body = err as { code?: unknown; error?: unknown }
if (body.code === "guardrail") return true
const nested = body.error
return (
typeof nested === "object" &&
nested !== null &&
(nested as { code?: unknown }).code === "guardrail"
)
}
/**
* Map a gateway failure to something a user can act on.
*
* Gateway status codes are deliberately not passed through to the browser. A 401
* relayed as 401 is indistinguishable from an expired session and would bounce the
* user to the login page, and a 429 collides with this app's own rate limiter, where
* 429 means "you clicked too fast". Everything that is the gateway's fault becomes 502.
*/
export function toAIGatewayError(err: unknown): AIGatewayError {
if (err instanceof AIGatewayError) return err
const status = statusOf(err)
if (isGuardrail(err) || status === 402) {
return new AIGatewayError(
"gateway budget exceeded",
502,
"The AI gateway's spending limit has been reached. Try again later or raise the budget in Switchboard.",
"budget"
)
}
if (status === 401 || status === 403) {
return new AIGatewayError(
"gateway rejected key",
502,
"The AI gateway rejected your API key. Update it in Settings.",
"auth"
)
}
if (status === 429) {
return new AIGatewayError(
"gateway rate limited",
502,
"The AI gateway is busy. Please wait a moment and try again.",
"rate_limit"
)
}
if (status === 502) {
return new AIGatewayError(
"all providers failed",
502,
"All AI providers are currently unavailable. Please try again in a moment.",
"unavailable"
)
}
if (status !== undefined && status >= 500) {
return new AIGatewayError(
`gateway returned ${status}`,
502,
"The AI gateway returned an error. Please try again.",
"unavailable"
)
}
// Timeouts and connection failures both arrive without a status, but they mean very
// different things to the user, so tell them apart.
if (isTimeout(err)) {
return new AIGatewayError(
"gateway timed out",
504,
"The AI request took too long and was cancelled. The model may be under load — please try again.",
"timeout"
)
}
if (isConnectionError(err)) {
return new AIGatewayError(
"gateway unreachable",
502,
`Can't reach the AI gateway at ${switchboardBaseUrl()}. Is Switchboard running?`,
"unreachable"
)
}
// Not recognisably the gateway's fault - most likely a parsing or database error in
// the calling route. Empty userMessage so the caller's own fallback text is used.
return new AIGatewayError(
err instanceof Error ? err.message : "unknown AI failure",
500,
"",
"unknown"
)
}
/**
* Standard error response for the AI routes. Clients already surface `error` from a
* non-2xx body, so these messages reach the user without any client change.
*/
export function aiErrorResponse(err: unknown, fallback: string) {
const mapped = toAIGatewayError(err)
console.error(`[switchboard] ${mapped.kind}:`, err)
return NextResponse.json(
{ error: mapped.userMessage || fallback, aiError: mapped.kind },
{ status: mapped.httpStatus }
)
}

View File

@@ -1,6 +1,5 @@
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "./provider-factory"
import { AI_PROVIDER, getUserProvider } from "./provider-factory"
import type {
ExtractedMenuItem,
MenuExtractionResult,
@@ -26,22 +25,13 @@ interface MenuAnalysisResult {
provider: string
}
async function getProviderForUser(userId: string) {
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId, isActive: true },
orderBy: { updatedAt: "desc" },
})
if (!apiKeyRecord) {
throw new Error(
"No active API key found. Please add an AI provider API key in Settings."
)
}
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
return { provider, providerName: apiKeyRecord.provider }
/**
* The gateway routes each request to a different backing model, so record which one
* actually answered rather than just "switchboard" - otherwise every scan looks
* identical in the history and there is no way to tell a bad extraction's source.
*/
function providerLabel(modelId: string | undefined): string {
return modelId ? `${AI_PROVIDER}:${modelId}` : AI_PROVIDER
}
async function getUserDrinkSummaries(
@@ -218,13 +208,15 @@ export async function analyzeMenu(
userId: string
): Promise<MenuAnalysisResult> {
// Step 1: Get AI provider for user
const { provider, providerName } = await getProviderForUser(userId)
const provider = await getUserProvider(userId)
// Step 2: Extract menu items from image
const extraction: MenuExtractionResult = await provider.extractMenuItems(
imageBase64,
mimeType
)
// Captured here because the recommendation call below overwrites lastMeta.
const extractionModel = providerLabel(provider.lastMeta?.model_id)
if (extraction.items.length === 0) {
return {
@@ -232,7 +224,7 @@ export async function analyzeMenu(
matchedItems: [],
recommendations: { recommendations: [], rawResponse: extraction.rawResponse },
rawResponse: extraction.rawResponse,
provider: providerName,
provider: extractionModel,
}
}
@@ -277,7 +269,7 @@ export async function analyzeMenu(
matchedItems: matched,
recommendations,
rawResponse: extraction.rawResponse,
provider: providerName,
provider: extractionModel,
}
}
@@ -286,6 +278,6 @@ export async function analyzeLabel(
mimeType: string,
userId: string
): Promise<LabelExtractionResult> {
const { provider } = await getProviderForUser(userId)
const provider = await getUserProvider(userId)
return provider.extractLabel(imageBase64, mimeType)
}

View File

@@ -1,81 +0,0 @@
import OpenAI from "openai"
import { BaseAIProvider } from "./base-provider"
export class OpenAIProvider extends BaseAIProvider {
name = "openai"
private client: OpenAI
constructor(apiKey: string) {
super()
this.client = new OpenAI({ apiKey })
}
async sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string> {
const dataUrl = `data:${mimeType};base64,${imageBase64}`
const response = await this.client.chat.completions.create({
model: "gpt-4o",
max_tokens: 4096,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: dataUrl,
detail: "high",
},
},
{
type: "text",
text: "Please analyze this image and extract the information as instructed.",
},
],
},
],
})
const message = response.choices[0]?.message?.content
if (!message) {
throw new Error("No response received from OpenAI")
}
return message
}
async sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string> {
const response = await this.client.chat.completions.create({
model: "gpt-4o",
max_tokens: 4096,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userMessage,
},
],
})
const message = response.choices[0]?.message?.content
if (!message) {
throw new Error("No response received from OpenAI")
}
return message
}
}

View File

@@ -1,14 +1,41 @@
import type { AIProvider } from "./types"
import { ClaudeProvider } from "./claude-provider"
import { OpenAIProvider } from "./openai-provider"
import { SwitchboardProvider } from "./switchboard-provider"
import { AIGatewayError } from "./errors"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
/** The only provider this app uses. Also the `provider` value stored on UserApiKey. */
export const AI_PROVIDER = "switchboard" as const
export const NO_KEY_MESSAGE =
"No Switchboard API key configured. Add one in Settings."
export function createProvider(providerName: string, apiKey: string): AIProvider {
switch (providerName) {
case "claude":
return new ClaudeProvider(apiKey)
case "openai":
return new OpenAIProvider(apiKey)
default:
throw new Error(`Unknown AI provider: "${providerName}". Supported providers: "claude", "openai".`)
}
if (providerName === AI_PROVIDER) return new SwitchboardProvider(apiKey)
throw new Error(
`Unsupported AI provider: "${providerName}". This app now routes all AI requests through the Switchboard gateway.`
)
}
/**
* Single source of truth for "give me this user's configured AI provider".
*
* Filtering on `provider` is what makes the migration from the old direct
* Claude/OpenAI integration safe: a leftover "claude" or "openai" row holds a vendor
* key that the gateway would reject, so those rows are ignored entirely and the user
* gets "add a key in Settings" rather than a confusing auth failure.
*/
export async function getUserProvider(
userId: string
): Promise<SwitchboardProvider> {
const record = await prisma.userApiKey.findFirst({
where: { userId, isActive: true, provider: AI_PROVIDER },
orderBy: { updatedAt: "desc" },
})
if (!record) {
throw new AIGatewayError("no api key", 400, NO_KEY_MESSAGE, "no_key")
}
return new SwitchboardProvider(decrypt(record.encryptedKey, record.iv))
}

112
src/lib/ai/routing.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { FeatureRouting, SwitchboardOptions } from "./switchboard-types"
/**
* Applied to every request.
*
* Both fields are set defensively rather than left to the key's defaults. An API key
* minted for a different tool can carry its own `category`/`prefer_free` defaults,
* and anything this app leaves unset silently inherits them. Verified against the
* gateway: an unset request inherited `category: "complex_coding"` and free-model
* routing from the key, which sent drink prompts to a free coding model.
*
* `prefer_free` is off because every call site here parses JSON out of the response
* and free models are the least reliable at emitting it, and because the recommend
* and bartender features send personal drink history and home bar inventory - the
* gateway guide notes free endpoints may log or train on prompts.
*/
const BASE: SwitchboardOptions = {
prefer_free: false,
peer_review: false,
}
/**
* Timeouts are generous for the same reason token budgets are: a routed reasoning
* model is slow. A plain drink search measured ~42s end to end, and latency varies
* with which model the router picks, so these are sized well above the typical case.
*
* Token budgets are deliberately generous. The router may pick a reasoning model,
* and reasoning tokens are drawn from the same `max_tokens` budget as the answer.
* Verified: an identical request returned `content: null` at max_tokens 512 (the
* whole budget went to reasoning) and correct JSON at 4096. Treat ~2048 as the floor
* for anything that must return content, not as a cost lever.
*/
export const FEATURE_ROUTING = {
// Vision. The only place `tier` earns its keep: these run once per deliberate user
// action and their output prefills a form, so a miss costs the user typing. On a
// test label, `frontier` read name/type/subType/abv correctly (~2.8s, $0.003) while
// unconstrained routing picked a small model that got the name but missed the ABV,
// and free routing returned only the name.
menuExtraction: {
feature: "menu.extract",
switchboard: { ...BASE, category: "general", tier: "frontier" },
timeoutMs: 240_000,
maxTokens: 4096,
},
labelExtraction: {
feature: "label.extract",
switchboard: { ...BASE, category: "general", tier: "frontier" },
timeoutMs: 180_000,
maxTokens: 4096,
},
// Short interactive lookups. Deliberately no `tier` - letting the classifier choose
// beat both alternatives by a wide margin when measured. `tier: "cheap"` pinned a
// slow reasoning model (42-180s, timed out twice in five trials and truncated its
// JSON once), and `tier: "frontier"` escalated as far as Claude Opus at $0.02 a
// call. Unconstrained, the same prompts landed on a small fast model in well under
// a second for a few hundredths of a cent.
drinkSearch: {
feature: "drink.search",
switchboard: { ...BASE, category: "simple" },
timeoutMs: 180_000,
maxTokens: 3072,
},
barcodeLookup: {
feature: "bar.barcode",
switchboard: { ...BASE, category: "simple" },
timeoutMs: 120_000,
maxTokens: 2048,
},
// General text.
menuRecommend: {
feature: "menu.recommend",
switchboard: { ...BASE, category: "general" },
timeoutMs: 180_000,
maxTokens: 4096,
},
bartenderSuggest: {
feature: "bartender.suggest",
switchboard: { ...BASE, category: "general" },
timeoutMs: 240_000,
maxTokens: 4096,
},
bartenderRecreate: {
feature: "bartender.recreate",
switchboard: { ...BASE, category: "general" },
timeoutMs: 180_000,
maxTokens: 3072,
},
recommendSuggest: {
feature: "recommend.suggest",
switchboard: { ...BASE, category: "general" },
timeoutMs: 180_000,
maxTokens: 4096,
},
recommendSimilar: {
feature: "recommend.similar",
switchboard: { ...BASE, category: "general" },
timeoutMs: 180_000,
maxTokens: 4096,
},
// Sends the user's whole rating history, and the result is persisted and then
// re-read by recommend/suggest and recommend/similar - a bad profile poisons both
// until it is regenerated, so this one does not get a cost lever.
flavorProfile: {
feature: "recommend.profile",
switchboard: { ...BASE, category: "business" },
timeoutMs: 240_000,
maxTokens: 4096,
},
} satisfies Record<string, FeatureRouting>

View File

@@ -0,0 +1,35 @@
import type { SwitchboardMeta } from "./switchboard-types"
/**
* One line per gateway call so the cost and the model actually used are visible in
* the server log. Called from inside the provider, so every feature gets it for free.
*/
export function logSwitchboardMeta(
feature: string,
meta: SwitchboardMeta | null
): void {
if (!meta) {
console.warn(`[switchboard] feature=${feature} no meta block in response`)
return
}
console.log(
`[switchboard] feature=${feature} model=${meta.model_id} ` +
`provider=${meta.provider} locality=${meta.locality} category=${meta.category} ` +
`cost=${meta.cost_usd ?? "?"} latency_ms=${meta.latency_ms} request_id=${meta.request_id}`
)
// Both of these silently degrade output quality, so they warn rather than log.
if (meta.failover) {
console.warn(
`[switchboard] feature=${feature} FAILOVER intended=${meta.intended_model} ` +
`actual=${meta.model_id} reason=${meta.reason}`
)
}
if (meta.context_overflow) {
console.warn(
`[switchboard] feature=${feature} CONTEXT OVERFLOW model=${meta.model_id} ` +
`- the provider may have truncated this request`
)
}
}

View File

@@ -0,0 +1,142 @@
import OpenAI from "openai"
import { BaseAIProvider } from "./base-provider"
import {
readSwitchboardMeta,
type FeatureRouting,
type SwitchboardMeta,
} from "./switchboard-types"
import { logSwitchboardMeta } from "./switchboard-log"
type ChatParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming
type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion
type ChatMessages = ChatParams["messages"]
/**
* The gateway's extra `switchboard` field is not part of the OpenAI schema. The SDK
* serializes the body as given and does not strip unknown keys, so the only obstacle
* is TypeScript's excess-property check - which fires on fresh object literals but not
* on a value of a named type. Hence this alias plus a widening cast at the call.
*
* If a future SDK version ever starts pruning unknown keys, the fallback is to bypass
* the typed resource method entirely: client.post("/chat/completions", { body }).
*/
type SwitchboardRequest = ChatParams & { switchboard?: Record<string, unknown> }
export const DEFAULT_SWITCHBOARD_BASE_URL = "http://192.168.2.11:8787/v1"
export function switchboardBaseUrl(): string {
return process.env.SWITCHBOARD_BASE_URL || DEFAULT_SWITCHBOARD_BASE_URL
}
/**
* Talks to Switchboard, an OpenAI-compatible gateway that routes each request to a
* backing model by cost, quality and speed. The model is always `switchboard/auto`:
* pinning a specific model id is an anti-pattern here because ids drift as the
* gateway refreshes its catalog, and a dead pin silently falls back to auto.
*/
export class SwitchboardProvider extends BaseAIProvider {
name = "switchboard"
/** Routing metadata from the most recent call. Read it immediately after awaiting. */
lastMeta: SwitchboardMeta | null = null
private client: OpenAI
constructor(apiKey: string) {
super()
this.client = new OpenAI({
apiKey,
baseURL: switchboardBaseUrl(),
// A gateway 502 already means "every candidate provider failed", so an SDK-level
// retry only doubles the wait before the user sees the error, and retrying a
// partially-billed request costs real money.
maxRetries: 0,
// Only a floor for calls that arrive without a FeatureRouting; every real call
// site sets its own, longer timeout.
timeout: 180_000,
})
}
async sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string,
routing?: FeatureRouting
): Promise<string> {
return this.complete(
[
{ role: "system", content: systemPrompt },
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: `data:${mimeType};base64,${imageBase64}`,
detail: "high",
},
},
{
type: "text",
text: "Please analyze this image and extract the information as instructed.",
},
],
},
],
routing
)
}
async sendTextRequest(
systemPrompt: string,
userMessage: string,
routing?: FeatureRouting
): Promise<string> {
return this.complete(
[
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
routing
)
}
private async complete(
messages: ChatMessages,
routing?: FeatureRouting
): Promise<string> {
const feature = routing?.feature ?? "unknown"
const levers = routing?.switchboard
const body: SwitchboardRequest = {
model: "switchboard/auto",
max_tokens: routing?.maxTokens ?? 4096,
messages,
...(levers && Object.keys(levers).length > 0
? { switchboard: levers as Record<string, unknown> }
: {}),
}
const completion = (await this.client.chat.completions.create(
body as ChatParams,
{ timeout: routing?.timeoutMs ?? 180_000 }
)) as ChatCompletion & { switchboard?: unknown }
this.lastMeta = readSwitchboardMeta(completion)
logSwitchboardMeta(feature, this.lastMeta)
const message = completion.choices?.[0]?.message?.content
if (!message) {
// Seen when the router picks a reasoning model and the whole token budget goes
// to reasoning before any answer is emitted. Naming the model makes it possible
// to tell that apart from an actual gateway fault.
throw new Error(
`Empty response from Switchboard (feature=${feature}, model=${
this.lastMeta?.model_id ?? "unknown"
}). The model may have exhausted its token budget.`
)
}
return message
}
}

View File

@@ -0,0 +1,78 @@
/**
* Types for the Switchboard gateway (OpenAI-compatible LLM router).
*
* Docs are served live by the gateway itself:
* GET /v1/guide - when/why to use each routing lever
* GET /v1/help - full request/response schema
*/
/**
* Routing levers sent verbatim as the `switchboard` object in the request body.
*
* Note that an API key carries its own routing defaults, chosen when the key was
* minted. Those defaults apply to any field the request does not set, so this app
* sets `category` and `prefer_free` explicitly on every call rather than inheriting
* whatever the key happens to be configured for.
*
* `conversation_id` is deliberately absent. Every call site here is a headless
* server request with no chat loop, and omitting it lets the gateway infer implicit
* feedback from the call pattern. Leaving it out of the type makes passing one an error.
*/
export interface SwitchboardOptions {
category?:
| "simple"
| "coding"
| "complex_coding"
| "business"
| "long_document"
| "general"
prefer_free?: boolean
prefer_local?: boolean
privacy?: boolean
tier?: "frontier" | "cheap" | "free" | "local"
peer_review?:
| boolean
| "second_opinion"
| "review_revise"
| "panel"
| "synthesize"
| "compare"
}
/**
* Everything a feature can tune. Only `switchboard` is serialized into the request
* body; the rest are app-local, so they must not leak into SwitchboardOptions.
*/
export interface FeatureRouting {
/** Short label used in logs, e.g. "menu.extract". */
feature: string
switchboard?: SwitchboardOptions
timeoutMs?: number
maxTokens?: number
}
/**
* The `switchboard` block attached to every gateway response. Every field is
* optional on purpose: this is observability, never business logic, so a gateway-side
* rename must never be able to throw.
*/
export interface SwitchboardMeta {
request_id?: string
model_id?: string
provider?: string
locality?: string
category?: string
reason?: string
cost_usd?: number
latency_ms?: number
failover?: boolean
intended_model?: string
context_overflow?: boolean
}
export function readSwitchboardMeta(response: unknown): SwitchboardMeta | null {
if (typeof response !== "object" || response === null) return null
const meta = (response as { switchboard?: unknown }).switchboard
if (typeof meta !== "object" || meta === null) return null
return meta as SwitchboardMeta
}

View File

@@ -1,3 +1,5 @@
import type { FeatureRouting, SwitchboardMeta } from "./switchboard-types"
export interface ExtractedMenuItem {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
@@ -42,8 +44,19 @@ export interface DrinkSearchResult {
export interface AIProvider {
name: string
sendTextRequest(systemPrompt: string, userMessage: string): Promise<string>
sendVisionRequest(systemPrompt: string, imageBase64: string, mimeType: string): Promise<string>
/** Routing metadata from the most recent call, if the provider reports it. */
readonly lastMeta?: SwitchboardMeta | null
sendTextRequest(
systemPrompt: string,
userMessage: string,
routing?: FeatureRouting
): Promise<string>
sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string,
routing?: FeatureRouting
): Promise<string>
extractMenuItems(imageBase64: string, mimeType: string): Promise<MenuExtractionResult>
recommendDrinks(
extractedItems: ExtractedMenuItem[],

View File

@@ -24,7 +24,7 @@ export const ratingCreateSchema = z.object({
export const ratingUpdateSchema = ratingCreateSchema.omit({ drinkId: true }).partial()
export const apiKeySchema = z.object({
provider: z.enum(["claude", "openai"]),
provider: z.literal("switchboard"),
apiKey: z.string().min(1, "API key is required"),
label: z.string().max(100).optional(),
})
@@ -34,7 +34,8 @@ export const userPreferenceSchema = z.object({
avoidedStyles: z.array(z.string().max(50)).max(20).optional(),
minAbv: z.number().min(0).max(100).optional().nullable(),
maxAbv: z.number().min(0).max(100).optional().nullable(),
defaultProvider: z.enum(["claude", "openai"]).optional().nullable(),
// defaultProvider is intentionally absent: it was stored but never read, and there
// is only one provider now. The Prisma column stays so restoring an old backup works.
})
export const wishlistCreateSchema = z.object({