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:
@@ -25,3 +25,8 @@ MINIO_USE_SSL="false"
|
|||||||
|
|
||||||
# Encryption (for API key storage)
|
# Encryption (for API key storage)
|
||||||
ENCRYPTION_KEY="generate-with: openssl rand -hex 32"
|
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"
|
||||||
|
|||||||
31
README.md
31
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.
|
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
|
## Learn More
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB:-drinktracker}"
|
DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB:-drinktracker}"
|
||||||
MINIO_ENDPOINT: "localhost"
|
MINIO_ENDPOINT: "localhost"
|
||||||
|
SWITCHBOARD_BASE_URL: "${SWITCHBOARD_BASE_URL:-http://192.168.2.11:8787/v1}"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-drinktracker}"
|
DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-drinktracker}"
|
||||||
MINIO_ENDPOINT: "minio"
|
MINIO_ENDPOINT: "minio"
|
||||||
|
SWITCHBOARD_BASE_URL: "${SWITCHBOARD_BASE_URL:-http://192.168.2.11:8787/v1}"
|
||||||
WATCHPACK_POLLING: "true"
|
WATCHPACK_POLLING: "true"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -245,6 +245,10 @@ if [[ "$SKIP_CONFIG" == "false" ]]; then
|
|||||||
prompt_secret AUTH_SECRET "NextAuth secret" "$(openssl rand -base64 32)"
|
prompt_secret AUTH_SECRET "NextAuth secret" "$(openssl rand -base64 32)"
|
||||||
prompt_secret ENC_KEY "Encryption key" "$(openssl rand -hex 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 ""
|
||||||
echo -e "${BOLD}OAuth Providers (optional — press Enter to skip):${NC}"
|
echo -e "${BOLD}OAuth Providers (optional — press Enter to skip):${NC}"
|
||||||
prompt_value GOOGLE_CID "Google Client ID" ""
|
prompt_value GOOGLE_CID "Google Client ID" ""
|
||||||
@@ -288,6 +292,9 @@ MINIO_USE_SSL="false"
|
|||||||
|
|
||||||
# ─── Encryption (for API key storage) ────────────────────
|
# ─── Encryption (for API key storage) ────────────────────
|
||||||
ENCRYPTION_KEY="${ENC_KEY}"
|
ENCRYPTION_KEY="${ENC_KEY}"
|
||||||
|
|
||||||
|
# ─── AI Gateway (Switchboard) ────────────────────────────
|
||||||
|
SWITCHBOARD_BASE_URL="${SWITCHBOARD_URL}"
|
||||||
ENVEOF
|
ENVEOF
|
||||||
|
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
@@ -403,7 +410,7 @@ echo " Update: $DC_CMD pull && $DC_CMD up -d"
|
|||||||
echo ""
|
echo ""
|
||||||
echo -e " ${BOLD}${YELLOW}Next steps:${NC}"
|
echo -e " ${BOLD}${YELLOW}Next steps:${NC}"
|
||||||
echo " 1. Open ${APP_URL_CHECK} and create your account"
|
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"
|
echo " 3. Set up a reverse proxy (nginx/Caddy) for HTTPS"
|
||||||
if [[ -z "${GOOGLE_CID:-}" ]] && [[ -z "${GITHUB_CID:-}" ]]; then
|
if [[ -z "${GOOGLE_CID:-}" ]] && [[ -z "${GITHUB_CID:-}" ]]; then
|
||||||
echo " 4. (Optional) Add OAuth providers in $ENV_FILE"
|
echo " 4. (Optional) Add OAuth providers in $ENV_FILE"
|
||||||
|
|||||||
49
package-lock.json
generated
49
package-lock.json
generated
@@ -8,7 +8,6 @@
|
|||||||
"name": "drinktracker-init",
|
"name": "drinktracker-init",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.78.0",
|
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@aws-sdk/client-s3": "^3.1000.0",
|
"@aws-sdk/client-s3": "^3.1000.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
@@ -52,26 +51,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@auth/core": {
|
||||||
"version": "0.41.1",
|
"version": "0.41.1",
|
||||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
|
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
|
||||||
@@ -962,15 +941,6 @@
|
|||||||
"node": ">=18.0.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||||
@@ -5919,19 +5889,6 @@
|
|||||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -7978,12 +7935,6 @@
|
|||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.78.0",
|
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@aws-sdk/client-s3": "^3.1000.0",
|
"@aws-sdk/client-s3": "^3.1000.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ model VerificationToken {
|
|||||||
model UserApiKey {
|
model UserApiKey {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
provider String // "claude" | "openai"
|
provider String // "switchboard" (legacy rows may be "claude" | "openai")
|
||||||
encryptedKey String @db.Text
|
encryptedKey String @db.Text
|
||||||
iv String // initialization vector for decryption
|
iv String // initialization vector for decryption
|
||||||
label String? // optional user-friendly label
|
label String? // optional user-friendly label
|
||||||
@@ -94,7 +94,7 @@ model UserPreference {
|
|||||||
avoidedStyles String[] // e.g., ["Sour", "Light Lager"]
|
avoidedStyles String[] // e.g., ["Sour", "Light Lager"]
|
||||||
minAbv Float?
|
minAbv Float?
|
||||||
maxAbv Float?
|
maxAbv Float?
|
||||||
defaultProvider String? // preferred AI provider
|
defaultProvider String? // deprecated and unused; kept so old backups still restore
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ model MenuScan {
|
|||||||
userId String
|
userId String
|
||||||
imageUrl String
|
imageUrl String
|
||||||
status ScanStatus @default(UPLOADING)
|
status ScanStatus @default(UPLOADING)
|
||||||
aiProvider String? // which provider was used
|
aiProvider String? // "switchboard:<model_id>" — the model the gateway routed to
|
||||||
aiRawResponse Json? // raw AI response for debugging
|
aiRawResponse Json? // raw AI response for debugging
|
||||||
errorMessage String? @db.Text
|
errorMessage String? @db.Text
|
||||||
processedAt DateTime?
|
processedAt DateTime?
|
||||||
@@ -227,7 +227,7 @@ model SearchCache {
|
|||||||
queryHash String // normalized (lowercase, trimmed)
|
queryHash String // normalized (lowercase, trimmed)
|
||||||
query String // original text
|
query String // original text
|
||||||
results Json // { drinks: [...] }
|
results Json // { drinks: [...] }
|
||||||
provider String // "claude" | "openai"
|
provider String // "switchboard"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@unique([userId, queryHash, provider])
|
@@unique([userId, queryHash, provider])
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ interface ApiKeyInfo {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiKeysResponse {
|
||||||
|
keys: ApiKeyInfo[]
|
||||||
|
gatewayUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
const { data: apiKeys = [] } = useQuery<ApiKeyInfo[]>({
|
const { data: apiKeyData } = useQuery<ApiKeysResponse>({
|
||||||
queryKey: ["api-keys"],
|
queryKey: ["api-keys"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch("/api/settings/api-keys")
|
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
|
// Preferences
|
||||||
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
||||||
queryKey: ["preferences"],
|
queryKey: ["preferences"],
|
||||||
@@ -74,16 +84,32 @@ export default function SettingsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Key className="h-5 w-5" />
|
<Key className="h-5 w-5" />
|
||||||
AI Provider Keys
|
AI Gateway
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<ApiKeyForm provider="claude" label="Anthropic Claude" existingKey={apiKeys.find(k => k.provider === "claude")} />
|
<ApiKeyForm
|
||||||
|
provider="switchboard"
|
||||||
|
label="Switchboard Gateway"
|
||||||
|
existingKey={apiKeys.find((k) => k.provider === "switchboard")}
|
||||||
|
/>
|
||||||
|
{legacyKeys.length > 0 && (
|
||||||
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<ApiKeyForm provider="openai" label="OpenAI GPT-4o" existingKey={apiKeys.find(k => k.provider === "openai")} />
|
<LegacyKeyNotice keys={legacyKeys} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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({
|
function ApiKeyForm({
|
||||||
provider,
|
provider,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { getUserProvider } from "@/lib/ai/provider-factory"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { aiErrorResponse } from "@/lib/ai/errors"
|
||||||
import { createProvider } from "@/lib/ai/provider-factory"
|
|
||||||
import { rateLimit } from "@/lib/rate-limit"
|
import { rateLimit } from "@/lib/rate-limit"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
@@ -73,19 +72,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const { imageBase64, mimeType, context } = parsed.data
|
const { imageBase64, mimeType, context } = parsed.data
|
||||||
|
|
||||||
const apiKeyRecord = await prisma.userApiKey.findFirst({
|
const provider = await getUserProvider(session.user.id)
|
||||||
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 result = await provider.extractLabel(imageBase64, mimeType)
|
const result = await provider.extractLabel(imageBase64, mimeType)
|
||||||
|
|
||||||
@@ -107,10 +94,6 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json(response)
|
return NextResponse.json(response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("AI identify error:", error)
|
return aiErrorResponse(error, "Failed to identify product. Please try again.")
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to identify product. Please try again." },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { AI_PROVIDER, getUserProvider } from "@/lib/ai/provider-factory"
|
||||||
import { createProvider } from "@/lib/ai/provider-factory"
|
import { aiErrorResponse } from "@/lib/ai/errors"
|
||||||
import { rateLimit } from "@/lib/rate-limit"
|
import { rateLimit } from "@/lib/rate-limit"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { Prisma } from "@prisma/client"
|
import type { Prisma } from "@prisma/client"
|
||||||
@@ -33,18 +33,6 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Invalid query" }, { status: 400 })
|
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)
|
// Check cache first (24hr TTL)
|
||||||
const queryHash = parsed.data.query.toLowerCase().trim()
|
const queryHash = parsed.data.query.toLowerCase().trim()
|
||||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||||
@@ -54,7 +42,7 @@ export async function POST(request: Request) {
|
|||||||
userId_queryHash_provider: {
|
userId_queryHash_provider: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
queryHash,
|
queryHash,
|
||||||
provider: apiKeyRecord.provider,
|
provider: AI_PROVIDER,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -63,8 +51,7 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json(cached.results)
|
return NextResponse.json(cached.results)
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
|
const provider = await getUserProvider(session.user.id)
|
||||||
const provider = createProvider(apiKeyRecord.provider, apiKey)
|
|
||||||
|
|
||||||
const result = await provider.searchDrinks(parsed.data.query)
|
const result = await provider.searchDrinks(parsed.data.query)
|
||||||
|
|
||||||
@@ -74,7 +61,7 @@ export async function POST(request: Request) {
|
|||||||
userId_queryHash_provider: {
|
userId_queryHash_provider: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
queryHash,
|
queryHash,
|
||||||
provider: apiKeyRecord.provider,
|
provider: AI_PROVIDER,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -87,16 +74,12 @@ export async function POST(request: Request) {
|
|||||||
queryHash,
|
queryHash,
|
||||||
query: parsed.data.query,
|
query: parsed.data.query,
|
||||||
results: { drinks: result.drinks } as unknown as Prisma.InputJsonValue,
|
results: { drinks: result.drinks } as unknown as Prisma.InputJsonValue,
|
||||||
provider: apiKeyRecord.provider,
|
provider: AI_PROVIDER,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ drinks: result.drinks })
|
return NextResponse.json({ drinks: result.drinks })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("AI search error:", error)
|
return aiErrorResponse(error, "Search failed. Please try again.")
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Search failed. Please try again." },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { getUserProvider } from "@/lib/ai/provider-factory"
|
||||||
import { createProvider } from "@/lib/ai/provider-factory"
|
import { FEATURE_ROUTING } from "@/lib/ai/routing"
|
||||||
import { rateLimit } from "@/lib/rate-limit"
|
import { rateLimit } from "@/lib/rate-limit"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
@@ -71,13 +71,7 @@ async function lookupOpenFoodFacts(barcode: string) {
|
|||||||
|
|
||||||
async function lookupViaAI(barcode: string, userId: string) {
|
async function lookupViaAI(barcode: string, userId: string) {
|
||||||
try {
|
try {
|
||||||
const apiKeyRecord = await prisma.userApiKey.findFirst({
|
const provider = await getUserProvider(userId)
|
||||||
where: { userId, isActive: true },
|
|
||||||
})
|
|
||||||
if (!apiKeyRecord) return null
|
|
||||||
|
|
||||||
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
|
|
||||||
const provider = createProvider(apiKeyRecord.provider, apiKey)
|
|
||||||
|
|
||||||
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.
|
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(
|
const response = await provider.sendTextRequest(
|
||||||
systemPrompt,
|
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]*\}/)
|
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,
|
brand: (parsed.brand as string) || null,
|
||||||
category: parsed.category || "SPIRITS",
|
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
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { getUserProvider } from "@/lib/ai/provider-factory"
|
||||||
import { createProvider } 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 { rateLimit } from "@/lib/rate-limit"
|
||||||
import { COCKTAIL_RECIPE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts"
|
import { COCKTAIL_RECIPE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts"
|
||||||
import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher"
|
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 })
|
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({
|
const barItems = await prisma.barItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -56,12 +46,12 @@ export async function POST(request: Request) {
|
|||||||
const inventoryString = buildBarInventoryString(barItems)
|
const inventoryString = buildBarInventoryString(barItems)
|
||||||
const prompt = COCKTAIL_RECIPE_PROMPT.replace("{barInventory}", inventoryString)
|
const prompt = COCKTAIL_RECIPE_PROMPT.replace("{barInventory}", inventoryString)
|
||||||
|
|
||||||
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
|
const provider = await getUserProvider(session.user.id)
|
||||||
const provider = createProvider(apiKeyRecord.provider, apiKey)
|
|
||||||
|
|
||||||
const rawResponse = await provider.sendTextRequest(
|
const rawResponse = await provider.sendTextRequest(
|
||||||
prompt,
|
prompt,
|
||||||
`Generate a recipe for: ${parsed.data.cocktailName}`
|
`Generate a recipe for: ${parsed.data.cocktailName}`,
|
||||||
|
FEATURE_ROUTING.bartenderRecreate
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse JSON from response
|
// Parse JSON from response
|
||||||
@@ -91,10 +81,6 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json(recipe)
|
return NextResponse.json(recipe)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Bartender recreate error:", error)
|
return aiErrorResponse(error, "Failed to generate recipe. Please try again.")
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to generate recipe. Please try again." },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { getUserProvider } from "@/lib/ai/provider-factory"
|
||||||
import { createProvider } 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 { rateLimit } from "@/lib/rate-limit"
|
||||||
import { WHAT_CAN_I_MAKE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts"
|
import { WHAT_CAN_I_MAKE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts"
|
||||||
import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher"
|
import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher"
|
||||||
@@ -22,17 +23,6 @@ export async function POST() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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({
|
const barItems = await prisma.barItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -51,12 +41,12 @@ export async function POST() {
|
|||||||
const inventoryString = buildBarInventoryString(barItems)
|
const inventoryString = buildBarInventoryString(barItems)
|
||||||
const prompt = WHAT_CAN_I_MAKE_PROMPT.replace("{barInventory}", inventoryString)
|
const prompt = WHAT_CAN_I_MAKE_PROMPT.replace("{barInventory}", inventoryString)
|
||||||
|
|
||||||
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
|
const provider = await getUserProvider(session.user.id)
|
||||||
const provider = createProvider(apiKeyRecord.provider, apiKey)
|
|
||||||
|
|
||||||
const rawResponse = await provider.sendTextRequest(
|
const rawResponse = await provider.sendTextRequest(
|
||||||
prompt,
|
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
|
// Parse JSON from response
|
||||||
@@ -98,10 +88,6 @@ export async function POST() {
|
|||||||
|
|
||||||
return NextResponse.json({ suggestions })
|
return NextResponse.json({ suggestions })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Bartender suggest error:", error)
|
return aiErrorResponse(error, "Failed to generate suggestions. Please try again.")
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to generate suggestions. Please try again." },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { getUserProvider } from "@/lib/ai/provider-factory"
|
||||||
import { createProvider } 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 { rateLimit } from "@/lib/rate-limit"
|
||||||
import { FLAVOR_PROFILE_PROMPT, buildDrinkHistoryString } from "@/lib/ai/prompts"
|
import { FLAVOR_PROFILE_PROMPT, buildDrinkHistoryString } from "@/lib/ai/prompts"
|
||||||
import type { Prisma } from "@prisma/client"
|
import type { Prisma } from "@prisma/client"
|
||||||
@@ -67,18 +68,6 @@ export async function POST() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Fetch all drinks with ratings
|
||||||
const drinks = await prisma.drink.findMany({
|
const drinks = await prisma.drink.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
@@ -120,12 +109,12 @@ export async function POST() {
|
|||||||
const drinkHistory = buildDrinkHistoryString(drinkSummaries)
|
const drinkHistory = buildDrinkHistoryString(drinkSummaries)
|
||||||
const prompt = FLAVOR_PROFILE_PROMPT.replace("{drinkHistory}", drinkHistory)
|
const prompt = FLAVOR_PROFILE_PROMPT.replace("{drinkHistory}", drinkHistory)
|
||||||
|
|
||||||
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
|
const provider = await getUserProvider(session.user.id)
|
||||||
const provider = createProvider(apiKeyRecord.provider, apiKey)
|
|
||||||
|
|
||||||
const rawResponse = await provider.sendTextRequest(
|
const rawResponse = await provider.sendTextRequest(
|
||||||
prompt,
|
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
|
// Parse the JSON response
|
||||||
@@ -183,10 +172,6 @@ export async function POST() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Flavor profile generation error:", error)
|
return aiErrorResponse(error, "Failed to generate flavor profile. Please try again.")
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to generate flavor profile. Please try again." },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { getUserProvider } from "@/lib/ai/provider-factory"
|
||||||
import { createProvider } 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 { rateLimit } from "@/lib/rate-limit"
|
||||||
import { SIMILAR_DRINK_PROMPT } from "@/lib/ai/prompts"
|
import { SIMILAR_DRINK_PROMPT } from "@/lib/ai/prompts"
|
||||||
import { z } from "zod"
|
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
|
// Fetch the source drink
|
||||||
const drink = await prisma.drink.findFirst({
|
const drink = await prisma.drink.findFirst({
|
||||||
where: { id: parsed.data.drinkId, userId: session.user.id },
|
where: { id: parsed.data.drinkId, userId: session.user.id },
|
||||||
@@ -99,12 +88,12 @@ export async function POST(request: Request) {
|
|||||||
.replace("{sourceDrink}", sourceDrink)
|
.replace("{sourceDrink}", sourceDrink)
|
||||||
.replace("{flavorProfile}", flavorProfile)
|
.replace("{flavorProfile}", flavorProfile)
|
||||||
|
|
||||||
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
|
const provider = await getUserProvider(session.user.id)
|
||||||
const provider = createProvider(apiKeyRecord.provider, apiKey)
|
|
||||||
|
|
||||||
const rawResponse = await provider.sendTextRequest(
|
const rawResponse = await provider.sendTextRequest(
|
||||||
prompt,
|
prompt,
|
||||||
`Find drinks similar to ${drink.name}.`
|
`Find drinks similar to ${drink.name}.`,
|
||||||
|
FEATURE_ROUTING.recommendSimilar
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse JSON response
|
// Parse JSON response
|
||||||
@@ -131,10 +120,6 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ recommendations, sourceDrink: drink.name })
|
return NextResponse.json({ recommendations, sourceDrink: drink.name })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Similar drink error:", error)
|
return aiErrorResponse(error, "Failed to find similar drinks. Please try again.")
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to find similar drinks. Please try again." },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { getUserProvider } from "@/lib/ai/provider-factory"
|
||||||
import { createProvider } 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 { rateLimit } from "@/lib/rate-limit"
|
||||||
import { RECOMMEND_DRINK_PROMPT } from "@/lib/ai/prompts"
|
import { RECOMMEND_DRINK_PROMPT } from "@/lib/ai/prompts"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -38,18 +39,6 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
|
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
|
// Fetch flavor profile
|
||||||
const profile = await prisma.flavorProfile.findUnique({
|
const profile = await prisma.flavorProfile.findUnique({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
@@ -82,12 +71,12 @@ export async function POST(request: Request) {
|
|||||||
.replace("{flavorProfile}", profile.profileText)
|
.replace("{flavorProfile}", profile.profileText)
|
||||||
.replace("{context}", context)
|
.replace("{context}", context)
|
||||||
|
|
||||||
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
|
const provider = await getUserProvider(session.user.id)
|
||||||
const provider = createProvider(apiKeyRecord.provider, apiKey)
|
|
||||||
|
|
||||||
const rawResponse = await provider.sendTextRequest(
|
const rawResponse = await provider.sendTextRequest(
|
||||||
prompt,
|
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
|
// Parse JSON response
|
||||||
@@ -114,10 +103,6 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ recommendations })
|
return NextResponse.json({ recommendations })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Drink suggestion error:", error)
|
return aiErrorResponse(error, "Failed to get suggestions. Please try again.")
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to get suggestions. Please try again." },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,12 +166,14 @@ async function processMenuScan(
|
|||||||
])
|
])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Menu scan processing failed:", 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({
|
await prisma.menuScan.update({
|
||||||
where: { id: scanId },
|
where: { id: scanId },
|
||||||
data: {
|
data: {
|
||||||
status: "FAILED",
|
status: "FAILED",
|
||||||
errorMessage:
|
errorMessage: toAIGatewayError(error).userMessage,
|
||||||
error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { auth } from "@/lib/auth"
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { encrypt, decrypt, maskApiKey } from "@/lib/encryption"
|
import { encrypt, decrypt, maskApiKey } from "@/lib/encryption"
|
||||||
import { apiKeySchema } from "@/lib/validators"
|
import { apiKeySchema } from "@/lib/validators"
|
||||||
|
import { switchboardBaseUrl } from "@/lib/ai/switchboard-provider"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth()
|
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) {
|
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({
|
return NextResponse.json({
|
||||||
id: key.id,
|
id: key.id,
|
||||||
provider: key.provider,
|
provider: key.provider,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
DRINK_SEARCH_PROMPT,
|
DRINK_SEARCH_PROMPT,
|
||||||
buildRecommendationPrompt,
|
buildRecommendationPrompt,
|
||||||
} from "./prompts"
|
} from "./prompts"
|
||||||
|
import type { FeatureRouting } from "./switchboard-types"
|
||||||
|
import { FEATURE_ROUTING } from "./routing"
|
||||||
|
|
||||||
export abstract class BaseAIProvider implements AIProvider {
|
export abstract class BaseAIProvider implements AIProvider {
|
||||||
abstract name: string
|
abstract name: string
|
||||||
@@ -22,12 +24,14 @@ export abstract class BaseAIProvider implements AIProvider {
|
|||||||
abstract sendVisionRequest(
|
abstract sendVisionRequest(
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
imageBase64: string,
|
imageBase64: string,
|
||||||
mimeType: string
|
mimeType: string,
|
||||||
|
routing?: FeatureRouting
|
||||||
): Promise<string>
|
): Promise<string>
|
||||||
|
|
||||||
abstract sendTextRequest(
|
abstract sendTextRequest(
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
userMessage: string
|
userMessage: string,
|
||||||
|
routing?: FeatureRouting
|
||||||
): Promise<string>
|
): Promise<string>
|
||||||
|
|
||||||
async extractMenuItems(
|
async extractMenuItems(
|
||||||
@@ -37,7 +41,8 @@ export abstract class BaseAIProvider implements AIProvider {
|
|||||||
const rawResponse = await this.sendVisionRequest(
|
const rawResponse = await this.sendVisionRequest(
|
||||||
MENU_EXTRACTION_PROMPT,
|
MENU_EXTRACTION_PROMPT,
|
||||||
imageBase64,
|
imageBase64,
|
||||||
mimeType
|
mimeType,
|
||||||
|
FEATURE_ROUTING.menuExtraction
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -74,7 +79,8 @@ export abstract class BaseAIProvider implements AIProvider {
|
|||||||
|
|
||||||
const rawResponse = await this.sendTextRequest(
|
const rawResponse = await this.sendTextRequest(
|
||||||
prompt,
|
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 {
|
try {
|
||||||
@@ -103,7 +109,8 @@ export abstract class BaseAIProvider implements AIProvider {
|
|||||||
const rawResponse = await this.sendVisionRequest(
|
const rawResponse = await this.sendVisionRequest(
|
||||||
LABEL_EXTRACTION_PROMPT,
|
LABEL_EXTRACTION_PROMPT,
|
||||||
imageBase64,
|
imageBase64,
|
||||||
mimeType
|
mimeType,
|
||||||
|
FEATURE_ROUTING.labelExtraction
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -132,7 +139,8 @@ export abstract class BaseAIProvider implements AIProvider {
|
|||||||
async searchDrinks(query: string): Promise<DrinkSearchResult> {
|
async searchDrinks(query: string): Promise<DrinkSearchResult> {
|
||||||
const rawResponse = await this.sendTextRequest(
|
const rawResponse = await this.sendTextRequest(
|
||||||
DRINK_SEARCH_PROMPT,
|
DRINK_SEARCH_PROMPT,
|
||||||
`Search for: ${query}`
|
`Search for: ${query}`,
|
||||||
|
FEATURE_ROUTING.drinkSearch
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
170
src/lib/ai/errors.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { decrypt } from "@/lib/encryption"
|
import { AI_PROVIDER, getUserProvider } from "./provider-factory"
|
||||||
import { createProvider } from "./provider-factory"
|
|
||||||
import type {
|
import type {
|
||||||
ExtractedMenuItem,
|
ExtractedMenuItem,
|
||||||
MenuExtractionResult,
|
MenuExtractionResult,
|
||||||
@@ -26,22 +25,13 @@ interface MenuAnalysisResult {
|
|||||||
provider: string
|
provider: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProviderForUser(userId: string) {
|
/**
|
||||||
const apiKeyRecord = await prisma.userApiKey.findFirst({
|
* The gateway routes each request to a different backing model, so record which one
|
||||||
where: { userId, isActive: true },
|
* actually answered rather than just "switchboard" - otherwise every scan looks
|
||||||
orderBy: { updatedAt: "desc" },
|
* identical in the history and there is no way to tell a bad extraction's source.
|
||||||
})
|
*/
|
||||||
|
function providerLabel(modelId: string | undefined): string {
|
||||||
if (!apiKeyRecord) {
|
return modelId ? `${AI_PROVIDER}:${modelId}` : AI_PROVIDER
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUserDrinkSummaries(
|
async function getUserDrinkSummaries(
|
||||||
@@ -218,13 +208,15 @@ export async function analyzeMenu(
|
|||||||
userId: string
|
userId: string
|
||||||
): Promise<MenuAnalysisResult> {
|
): Promise<MenuAnalysisResult> {
|
||||||
// Step 1: Get AI provider for user
|
// 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
|
// Step 2: Extract menu items from image
|
||||||
const extraction: MenuExtractionResult = await provider.extractMenuItems(
|
const extraction: MenuExtractionResult = await provider.extractMenuItems(
|
||||||
imageBase64,
|
imageBase64,
|
||||||
mimeType
|
mimeType
|
||||||
)
|
)
|
||||||
|
// Captured here because the recommendation call below overwrites lastMeta.
|
||||||
|
const extractionModel = providerLabel(provider.lastMeta?.model_id)
|
||||||
|
|
||||||
if (extraction.items.length === 0) {
|
if (extraction.items.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -232,7 +224,7 @@ export async function analyzeMenu(
|
|||||||
matchedItems: [],
|
matchedItems: [],
|
||||||
recommendations: { recommendations: [], rawResponse: extraction.rawResponse },
|
recommendations: { recommendations: [], rawResponse: extraction.rawResponse },
|
||||||
rawResponse: extraction.rawResponse,
|
rawResponse: extraction.rawResponse,
|
||||||
provider: providerName,
|
provider: extractionModel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +269,7 @@ export async function analyzeMenu(
|
|||||||
matchedItems: matched,
|
matchedItems: matched,
|
||||||
recommendations,
|
recommendations,
|
||||||
rawResponse: extraction.rawResponse,
|
rawResponse: extraction.rawResponse,
|
||||||
provider: providerName,
|
provider: extractionModel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +278,6 @@ export async function analyzeLabel(
|
|||||||
mimeType: string,
|
mimeType: string,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<LabelExtractionResult> {
|
): Promise<LabelExtractionResult> {
|
||||||
const { provider } = await getProviderForUser(userId)
|
const provider = await getUserProvider(userId)
|
||||||
return provider.extractLabel(imageBase64, mimeType)
|
return provider.extractLabel(imageBase64, mimeType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,41 @@
|
|||||||
import type { AIProvider } from "./types"
|
import type { AIProvider } from "./types"
|
||||||
import { ClaudeProvider } from "./claude-provider"
|
import { SwitchboardProvider } from "./switchboard-provider"
|
||||||
import { OpenAIProvider } from "./openai-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 {
|
export function createProvider(providerName: string, apiKey: string): AIProvider {
|
||||||
switch (providerName) {
|
if (providerName === AI_PROVIDER) return new SwitchboardProvider(apiKey)
|
||||||
case "claude":
|
throw new Error(
|
||||||
return new ClaudeProvider(apiKey)
|
`Unsupported AI provider: "${providerName}". This app now routes all AI requests through the Switchboard gateway.`
|
||||||
case "openai":
|
)
|
||||||
return new OpenAIProvider(apiKey)
|
}
|
||||||
default:
|
|
||||||
throw new Error(`Unknown AI provider: "${providerName}". Supported providers: "claude", "openai".`)
|
/**
|
||||||
}
|
* 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
112
src/lib/ai/routing.ts
Normal 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>
|
||||||
35
src/lib/ai/switchboard-log.ts
Normal file
35
src/lib/ai/switchboard-log.ts
Normal 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`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/lib/ai/switchboard-provider.ts
Normal file
142
src/lib/ai/switchboard-provider.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/lib/ai/switchboard-types.ts
Normal file
78
src/lib/ai/switchboard-types.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { FeatureRouting, SwitchboardMeta } from "./switchboard-types"
|
||||||
|
|
||||||
export interface ExtractedMenuItem {
|
export interface ExtractedMenuItem {
|
||||||
name: string
|
name: string
|
||||||
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
|
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
|
||||||
@@ -42,8 +44,19 @@ export interface DrinkSearchResult {
|
|||||||
|
|
||||||
export interface AIProvider {
|
export interface AIProvider {
|
||||||
name: string
|
name: string
|
||||||
sendTextRequest(systemPrompt: string, userMessage: string): Promise<string>
|
/** Routing metadata from the most recent call, if the provider reports it. */
|
||||||
sendVisionRequest(systemPrompt: string, imageBase64: string, mimeType: string): Promise<string>
|
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>
|
extractMenuItems(imageBase64: string, mimeType: string): Promise<MenuExtractionResult>
|
||||||
recommendDrinks(
|
recommendDrinks(
|
||||||
extractedItems: ExtractedMenuItem[],
|
extractedItems: ExtractedMenuItem[],
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const ratingCreateSchema = z.object({
|
|||||||
export const ratingUpdateSchema = ratingCreateSchema.omit({ drinkId: true }).partial()
|
export const ratingUpdateSchema = ratingCreateSchema.omit({ drinkId: true }).partial()
|
||||||
|
|
||||||
export const apiKeySchema = z.object({
|
export const apiKeySchema = z.object({
|
||||||
provider: z.enum(["claude", "openai"]),
|
provider: z.literal("switchboard"),
|
||||||
apiKey: z.string().min(1, "API key is required"),
|
apiKey: z.string().min(1, "API key is required"),
|
||||||
label: z.string().max(100).optional(),
|
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(),
|
avoidedStyles: z.array(z.string().max(50)).max(20).optional(),
|
||||||
minAbv: z.number().min(0).max(100).optional().nullable(),
|
minAbv: z.number().min(0).max(100).optional().nullable(),
|
||||||
maxAbv: 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({
|
export const wishlistCreateSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user