diff --git a/.env.example b/.env.example index cdcc62a..4eebc45 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,8 @@ MINIO_USE_SSL="false" # Encryption (for API key storage) ENCRYPTION_KEY="generate-with: openssl rand -hex 32" + +# AI Gateway (Switchboard) +# OpenAI-compatible router that picks the best model per request. LAN-only, plain HTTP. +# Each user adds their own gateway API key in Settings; this is only the endpoint. +SWITCHBOARD_BASE_URL="http://192.168.2.11:8787/v1" diff --git a/README.md b/README.md index e215bc4..339b238 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,37 @@ You can start editing the page by modifying `app/page.tsx`. The page auto-update This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## AI Gateway (Switchboard) + +All AI features — menu scanning, label identification, drink search, the bartender +and the recommendation engine — go through [Switchboard](http://192.168.2.11:8787/v1/guide), +an OpenAI-compatible gateway that routes each request to the best available model. +The app never pins a model id; it always sends `switchboard/auto` and lets the gateway +choose, then logs which model answered and what it cost. + +Setup: + +1. Set `SWITCHBOARD_BASE_URL` in your env file (defaults to `http://192.168.2.11:8787/v1`). +2. Mint an API key in the Switchboard UI under Settings → API keys. +3. Add that key in the app under Settings → AI Gateway. + +Per-feature routing (cost/quality levers, token budgets, timeouts) lives in +`src/lib/ai/routing.ts`. Note that a Switchboard key carries its own routing defaults, +so the app sets `category` and `prefer_free` explicitly on every request rather than +inheriting whatever the key was minted for. + +### Migrating from the old Claude/OpenAI integration + +Earlier versions stored a per-user Anthropic or OpenAI key. Those rows are ignored at +runtime and the Settings page offers to remove them, so no migration is required. To +clear them in bulk instead: + +```sql +DELETE FROM "UserApiKey" WHERE provider IN ('claude','openai'); +DELETE FROM "SearchCache" WHERE provider IN ('claude','openai'); +UPDATE "UserPreference" SET "defaultProvider" = NULL; +``` + ## Learn More To learn more about Next.js, take a look at the following resources: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 829b11e..761a7cf 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -92,6 +92,7 @@ services: environment: DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB:-drinktracker}" MINIO_ENDPOINT: "localhost" + SWITCHBOARD_BASE_URL: "${SWITCHBOARD_BASE_URL:-http://192.168.2.11:8787/v1}" volumes: pgdata: diff --git a/docker-compose.yml b/docker-compose.yml index 6662ded..8a81aab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,7 @@ services: environment: DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-drinktracker}" MINIO_ENDPOINT: "minio" + SWITCHBOARD_BASE_URL: "${SWITCHBOARD_BASE_URL:-http://192.168.2.11:8787/v1}" WATCHPACK_POLLING: "true" depends_on: db: diff --git a/install.sh b/install.sh index 76790c6..f15d1f9 100644 --- a/install.sh +++ b/install.sh @@ -245,6 +245,10 @@ if [[ "$SKIP_CONFIG" == "false" ]]; then prompt_secret AUTH_SECRET "NextAuth secret" "$(openssl rand -base64 32)" prompt_secret ENC_KEY "Encryption key" "$(openssl rand -hex 32)" + echo "" + echo -e "${BOLD}AI Gateway:${NC}" + prompt_value SWITCHBOARD_URL "Switchboard gateway URL" "http://192.168.2.11:8787/v1" + echo "" echo -e "${BOLD}OAuth Providers (optional — press Enter to skip):${NC}" prompt_value GOOGLE_CID "Google Client ID" "" @@ -288,6 +292,9 @@ MINIO_USE_SSL="false" # ─── Encryption (for API key storage) ──────────────────── ENCRYPTION_KEY="${ENC_KEY}" + +# ─── AI Gateway (Switchboard) ──────────────────────────── +SWITCHBOARD_BASE_URL="${SWITCHBOARD_URL}" ENVEOF chmod 600 "$ENV_FILE" @@ -403,7 +410,7 @@ echo " Update: $DC_CMD pull && $DC_CMD up -d" echo "" echo -e " ${BOLD}${YELLOW}Next steps:${NC}" echo " 1. Open ${APP_URL_CHECK} and create your account" -echo " 2. Add your Claude or OpenAI API key in Settings" +echo " 2. Add your Switchboard API key in Settings" echo " 3. Set up a reverse proxy (nginx/Caddy) for HTTPS" if [[ -z "${GOOGLE_CID:-}" ]] && [[ -z "${GITHUB_CID:-}" ]]; then echo " 4. (Optional) Add OAuth providers in $ENV_FILE" diff --git a/package-lock.json b/package-lock.json index 822c276..ae3a141 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "drinktracker-init", "version": "0.1.0", "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-s3": "^3.1000.0", "@prisma/client": "^6.19.2", @@ -52,26 +51,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.78.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", - "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, "node_modules/@auth/core": { "version": "0.41.1", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", @@ -962,15 +941,6 @@ "node": ">=18.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -5919,19 +5889,6 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7978,12 +7935,6 @@ "node": ">=8.0" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", diff --git a/package.json b/package.json index be4a808..a5e65de 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "lint": "next lint" }, "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-s3": "^3.1000.0", "@prisma/client": "^6.19.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51f11e5..53169f9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,7 +74,7 @@ model VerificationToken { model UserApiKey { id String @id @default(cuid()) userId String - provider String // "claude" | "openai" + provider String // "switchboard" (legacy rows may be "claude" | "openai") encryptedKey String @db.Text iv String // initialization vector for decryption label String? // optional user-friendly label @@ -94,7 +94,7 @@ model UserPreference { avoidedStyles String[] // e.g., ["Sour", "Light Lager"] minAbv Float? maxAbv Float? - defaultProvider String? // preferred AI provider + defaultProvider String? // deprecated and unused; kept so old backups still restore createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -163,7 +163,7 @@ model MenuScan { userId String imageUrl String status ScanStatus @default(UPLOADING) - aiProvider String? // which provider was used + aiProvider String? // "switchboard:" — the model the gateway routed to aiRawResponse Json? // raw AI response for debugging errorMessage String? @db.Text processedAt DateTime? @@ -227,7 +227,7 @@ model SearchCache { queryHash String // normalized (lowercase, trimmed) query String // original text results Json // { drinks: [...] } - provider String // "claude" | "openai" + provider String // "switchboard" createdAt DateTime @default(now()) @@unique([userId, queryHash, provider]) diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 2ba22b4..f4d48af 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -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({ + const { data: apiKeyData } = useQuery({ 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() { - AI Provider Keys + AI Gateway - 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{" "} + {apiKeyData.gatewayUrl}. + + )} - k.provider === "claude")} /> - - k.provider === "openai")} /> + k.provider === "switchboard")} + /> + {legacyKeys.length > 0 && ( + <> + + + + )} @@ -115,6 +141,62 @@ export default function SettingsPage() { ) } +const LEGACY_PROVIDER_LABELS: Record = { + 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 ( +
+

+ These keys are from an earlier version that called each AI provider directly. + They are no longer used and can be removed. +

+ {keys.map((key) => ( +
+
+

+ {LEGACY_PROVIDER_LABELS[key.provider] ?? key.provider} +

+ + {key.maskedKey} + +
+ +
+ ))} +
+ ) +} + function ApiKeyForm({ provider, label, diff --git a/src/app/api/ai/identify/route.ts b/src/app/api/ai/identify/route.ts index a063cc0..c0ffde9 100644 --- a/src/app/api/ai/identify/route.ts +++ b/src/app/api/ai/identify/route.ts @@ -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.") } } diff --git a/src/app/api/ai/search/route.ts b/src/app/api/ai/search/route.ts index b9eb4b1..e360720 100644 --- a/src/app/api/ai/search/route.ts +++ b/src/app/api/ai/search/route.ts @@ -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.") } } diff --git a/src/app/api/bar/barcode-lookup/route.ts b/src/app/api/bar/barcode-lookup/route.ts index 33b38d2..6f2d472 100644 --- a/src/app/api/bar/barcode-lookup/route.ts +++ b/src/app/api/bar/barcode-lookup/route.ts @@ -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 } } diff --git a/src/app/api/bartender/recreate/route.ts b/src/app/api/bartender/recreate/route.ts index 27fd089..413687f 100644 --- a/src/app/api/bartender/recreate/route.ts +++ b/src/app/api/bartender/recreate/route.ts @@ -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.") } } diff --git a/src/app/api/bartender/suggest/route.ts b/src/app/api/bartender/suggest/route.ts index df9d7b4..db4c712 100644 --- a/src/app/api/bartender/suggest/route.ts +++ b/src/app/api/bartender/suggest/route.ts @@ -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.") } } diff --git a/src/app/api/recommend/profile/route.ts b/src/app/api/recommend/profile/route.ts index d794595..364027f 100644 --- a/src/app/api/recommend/profile/route.ts +++ b/src/app/api/recommend/profile/route.ts @@ -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.") } } diff --git a/src/app/api/recommend/similar/route.ts b/src/app/api/recommend/similar/route.ts index dacb1d2..3ada199 100644 --- a/src/app/api/recommend/similar/route.ts +++ b/src/app/api/recommend/similar/route.ts @@ -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.") } } diff --git a/src/app/api/recommend/suggest/route.ts b/src/app/api/recommend/suggest/route.ts index 9061e9e..a4dd07d 100644 --- a/src/app/api/recommend/suggest/route.ts +++ b/src/app/api/recommend/suggest/route.ts @@ -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.") } } diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts index 9b8bd53..34b6efd 100644 --- a/src/app/api/scan/route.ts +++ b/src/app/api/scan/route.ts @@ -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, }, }) } diff --git a/src/app/api/settings/api-keys/route.ts b/src/app/api/settings/api-keys/route.ts index ae9f113..813d398 100644 --- a/src/app/api/settings/api-keys/route.ts +++ b/src/app/api/settings/api-keys/route.ts @@ -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, diff --git a/src/lib/ai/base-provider.ts b/src/lib/ai/base-provider.ts index afa6374..f0e8158 100644 --- a/src/lib/ai/base-provider.ts +++ b/src/lib/ai/base-provider.ts @@ -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 abstract sendTextRequest( systemPrompt: string, - userMessage: string + userMessage: string, + routing?: FeatureRouting ): Promise 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 { const rawResponse = await this.sendTextRequest( DRINK_SEARCH_PROMPT, - `Search for: ${query}` + `Search for: ${query}`, + FEATURE_ROUTING.drinkSearch ) try { diff --git a/src/lib/ai/claude-provider.ts b/src/lib/ai/claude-provider.ts deleted file mode 100644 index 05da558..0000000 --- a/src/lib/ai/claude-provider.ts +++ /dev/null @@ -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 { - 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 { - 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 - } -} diff --git a/src/lib/ai/errors.ts b/src/lib/ai/errors.ts new file mode 100644 index 0000000..bdd2182 --- /dev/null +++ b/src/lib/ai/errors.ts @@ -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 } + ) +} diff --git a/src/lib/ai/menu-analyzer.ts b/src/lib/ai/menu-analyzer.ts index 73d5eef..ee33106 100644 --- a/src/lib/ai/menu-analyzer.ts +++ b/src/lib/ai/menu-analyzer.ts @@ -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 { // 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 { - const { provider } = await getProviderForUser(userId) + const provider = await getUserProvider(userId) return provider.extractLabel(imageBase64, mimeType) } diff --git a/src/lib/ai/openai-provider.ts b/src/lib/ai/openai-provider.ts deleted file mode 100644 index 127afb2..0000000 --- a/src/lib/ai/openai-provider.ts +++ /dev/null @@ -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 { - 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 { - 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 - } -} diff --git a/src/lib/ai/provider-factory.ts b/src/lib/ai/provider-factory.ts index 996e17c..98f1dff 100644 --- a/src/lib/ai/provider-factory.ts +++ b/src/lib/ai/provider-factory.ts @@ -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 { + 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)) } diff --git a/src/lib/ai/routing.ts b/src/lib/ai/routing.ts new file mode 100644 index 0000000..ac928b7 --- /dev/null +++ b/src/lib/ai/routing.ts @@ -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 diff --git a/src/lib/ai/switchboard-log.ts b/src/lib/ai/switchboard-log.ts new file mode 100644 index 0000000..b8f48a7 --- /dev/null +++ b/src/lib/ai/switchboard-log.ts @@ -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` + ) + } +} diff --git a/src/lib/ai/switchboard-provider.ts b/src/lib/ai/switchboard-provider.ts new file mode 100644 index 0000000..c142d16 --- /dev/null +++ b/src/lib/ai/switchboard-provider.ts @@ -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 } + +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 { + 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 { + return this.complete( + [ + { role: "system", content: systemPrompt }, + { role: "user", content: userMessage }, + ], + routing + ) + } + + private async complete( + messages: ChatMessages, + routing?: FeatureRouting + ): Promise { + 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 } + : {}), + } + + 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 + } +} diff --git a/src/lib/ai/switchboard-types.ts b/src/lib/ai/switchboard-types.ts new file mode 100644 index 0000000..47d76a2 --- /dev/null +++ b/src/lib/ai/switchboard-types.ts @@ -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 +} diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 0bf43c1..7796e0a 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -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 - sendVisionRequest(systemPrompt: string, imageBase64: string, mimeType: string): Promise + /** Routing metadata from the most recent call, if the provider reports it. */ + readonly lastMeta?: SwitchboardMeta | null + sendTextRequest( + systemPrompt: string, + userMessage: string, + routing?: FeatureRouting + ): Promise + sendVisionRequest( + systemPrompt: string, + imageBase64: string, + mimeType: string, + routing?: FeatureRouting + ): Promise extractMenuItems(imageBase64: string, mimeType: string): Promise recommendDrinks( extractedItems: ExtractedMenuItem[], diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 2410b5e..2d62780 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -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({