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:
@@ -15,6 +15,8 @@ import {
|
||||
DRINK_SEARCH_PROMPT,
|
||||
buildRecommendationPrompt,
|
||||
} from "./prompts"
|
||||
import type { FeatureRouting } from "./switchboard-types"
|
||||
import { FEATURE_ROUTING } from "./routing"
|
||||
|
||||
export abstract class BaseAIProvider implements AIProvider {
|
||||
abstract name: string
|
||||
@@ -22,12 +24,14 @@ export abstract class BaseAIProvider implements AIProvider {
|
||||
abstract sendVisionRequest(
|
||||
systemPrompt: string,
|
||||
imageBase64: string,
|
||||
mimeType: string
|
||||
mimeType: string,
|
||||
routing?: FeatureRouting
|
||||
): Promise<string>
|
||||
|
||||
abstract sendTextRequest(
|
||||
systemPrompt: string,
|
||||
userMessage: string
|
||||
userMessage: string,
|
||||
routing?: FeatureRouting
|
||||
): Promise<string>
|
||||
|
||||
async extractMenuItems(
|
||||
@@ -37,7 +41,8 @@ export abstract class BaseAIProvider implements AIProvider {
|
||||
const rawResponse = await this.sendVisionRequest(
|
||||
MENU_EXTRACTION_PROMPT,
|
||||
imageBase64,
|
||||
mimeType
|
||||
mimeType,
|
||||
FEATURE_ROUTING.menuExtraction
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -74,7 +79,8 @@ export abstract class BaseAIProvider implements AIProvider {
|
||||
|
||||
const rawResponse = await this.sendTextRequest(
|
||||
prompt,
|
||||
"Please provide your drink recommendations based on the information above."
|
||||
"Please provide your drink recommendations based on the information above.",
|
||||
FEATURE_ROUTING.menuRecommend
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -103,7 +109,8 @@ export abstract class BaseAIProvider implements AIProvider {
|
||||
const rawResponse = await this.sendVisionRequest(
|
||||
LABEL_EXTRACTION_PROMPT,
|
||||
imageBase64,
|
||||
mimeType
|
||||
mimeType,
|
||||
FEATURE_ROUTING.labelExtraction
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -132,7 +139,8 @@ export abstract class BaseAIProvider implements AIProvider {
|
||||
async searchDrinks(query: string): Promise<DrinkSearchResult> {
|
||||
const rawResponse = await this.sendTextRequest(
|
||||
DRINK_SEARCH_PROMPT,
|
||||
`Search for: ${query}`
|
||||
`Search for: ${query}`,
|
||||
FEATURE_ROUTING.drinkSearch
|
||||
)
|
||||
|
||||
try {
|
||||
|
||||
@@ -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 { decrypt } from "@/lib/encryption"
|
||||
import { createProvider } from "./provider-factory"
|
||||
import { AI_PROVIDER, getUserProvider } from "./provider-factory"
|
||||
import type {
|
||||
ExtractedMenuItem,
|
||||
MenuExtractionResult,
|
||||
@@ -26,22 +25,13 @@ interface MenuAnalysisResult {
|
||||
provider: string
|
||||
}
|
||||
|
||||
async function getProviderForUser(userId: string) {
|
||||
const apiKeyRecord = await prisma.userApiKey.findFirst({
|
||||
where: { userId, isActive: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
})
|
||||
|
||||
if (!apiKeyRecord) {
|
||||
throw new Error(
|
||||
"No active API key found. Please add an AI provider API key in Settings."
|
||||
)
|
||||
}
|
||||
|
||||
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
|
||||
const provider = createProvider(apiKeyRecord.provider, apiKey)
|
||||
|
||||
return { provider, providerName: apiKeyRecord.provider }
|
||||
/**
|
||||
* The gateway routes each request to a different backing model, so record which one
|
||||
* actually answered rather than just "switchboard" - otherwise every scan looks
|
||||
* identical in the history and there is no way to tell a bad extraction's source.
|
||||
*/
|
||||
function providerLabel(modelId: string | undefined): string {
|
||||
return modelId ? `${AI_PROVIDER}:${modelId}` : AI_PROVIDER
|
||||
}
|
||||
|
||||
async function getUserDrinkSummaries(
|
||||
@@ -218,13 +208,15 @@ export async function analyzeMenu(
|
||||
userId: string
|
||||
): Promise<MenuAnalysisResult> {
|
||||
// Step 1: Get AI provider for user
|
||||
const { provider, providerName } = await getProviderForUser(userId)
|
||||
const provider = await getUserProvider(userId)
|
||||
|
||||
// Step 2: Extract menu items from image
|
||||
const extraction: MenuExtractionResult = await provider.extractMenuItems(
|
||||
imageBase64,
|
||||
mimeType
|
||||
)
|
||||
// Captured here because the recommendation call below overwrites lastMeta.
|
||||
const extractionModel = providerLabel(provider.lastMeta?.model_id)
|
||||
|
||||
if (extraction.items.length === 0) {
|
||||
return {
|
||||
@@ -232,7 +224,7 @@ export async function analyzeMenu(
|
||||
matchedItems: [],
|
||||
recommendations: { recommendations: [], rawResponse: extraction.rawResponse },
|
||||
rawResponse: extraction.rawResponse,
|
||||
provider: providerName,
|
||||
provider: extractionModel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +269,7 @@ export async function analyzeMenu(
|
||||
matchedItems: matched,
|
||||
recommendations,
|
||||
rawResponse: extraction.rawResponse,
|
||||
provider: providerName,
|
||||
provider: extractionModel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +278,6 @@ export async function analyzeLabel(
|
||||
mimeType: string,
|
||||
userId: string
|
||||
): Promise<LabelExtractionResult> {
|
||||
const { provider } = await getProviderForUser(userId)
|
||||
const provider = await getUserProvider(userId)
|
||||
return provider.extractLabel(imageBase64, mimeType)
|
||||
}
|
||||
|
||||
@@ -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 { ClaudeProvider } from "./claude-provider"
|
||||
import { OpenAIProvider } from "./openai-provider"
|
||||
import { SwitchboardProvider } from "./switchboard-provider"
|
||||
import { AIGatewayError } from "./errors"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { decrypt } from "@/lib/encryption"
|
||||
|
||||
/** The only provider this app uses. Also the `provider` value stored on UserApiKey. */
|
||||
export const AI_PROVIDER = "switchboard" as const
|
||||
|
||||
export const NO_KEY_MESSAGE =
|
||||
"No Switchboard API key configured. Add one in Settings."
|
||||
|
||||
export function createProvider(providerName: string, apiKey: string): AIProvider {
|
||||
switch (providerName) {
|
||||
case "claude":
|
||||
return new ClaudeProvider(apiKey)
|
||||
case "openai":
|
||||
return new OpenAIProvider(apiKey)
|
||||
default:
|
||||
throw new Error(`Unknown AI provider: "${providerName}". Supported providers: "claude", "openai".`)
|
||||
}
|
||||
if (providerName === AI_PROVIDER) return new SwitchboardProvider(apiKey)
|
||||
throw new Error(
|
||||
`Unsupported AI provider: "${providerName}". This app now routes all AI requests through the Switchboard gateway.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for "give me this user's configured AI provider".
|
||||
*
|
||||
* Filtering on `provider` is what makes the migration from the old direct
|
||||
* Claude/OpenAI integration safe: a leftover "claude" or "openai" row holds a vendor
|
||||
* key that the gateway would reject, so those rows are ignored entirely and the user
|
||||
* gets "add a key in Settings" rather than a confusing auth failure.
|
||||
*/
|
||||
export async function getUserProvider(
|
||||
userId: string
|
||||
): Promise<SwitchboardProvider> {
|
||||
const record = await prisma.userApiKey.findFirst({
|
||||
where: { userId, isActive: true, provider: AI_PROVIDER },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
})
|
||||
|
||||
if (!record) {
|
||||
throw new AIGatewayError("no api key", 400, NO_KEY_MESSAGE, "no_key")
|
||||
}
|
||||
|
||||
return new SwitchboardProvider(decrypt(record.encryptedKey, record.iv))
|
||||
}
|
||||
|
||||
112
src/lib/ai/routing.ts
Normal file
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 {
|
||||
name: string
|
||||
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
|
||||
@@ -42,8 +44,19 @@ export interface DrinkSearchResult {
|
||||
|
||||
export interface AIProvider {
|
||||
name: string
|
||||
sendTextRequest(systemPrompt: string, userMessage: string): Promise<string>
|
||||
sendVisionRequest(systemPrompt: string, imageBase64: string, mimeType: string): Promise<string>
|
||||
/** Routing metadata from the most recent call, if the provider reports it. */
|
||||
readonly lastMeta?: SwitchboardMeta | null
|
||||
sendTextRequest(
|
||||
systemPrompt: string,
|
||||
userMessage: string,
|
||||
routing?: FeatureRouting
|
||||
): Promise<string>
|
||||
sendVisionRequest(
|
||||
systemPrompt: string,
|
||||
imageBase64: string,
|
||||
mimeType: string,
|
||||
routing?: FeatureRouting
|
||||
): Promise<string>
|
||||
extractMenuItems(imageBase64: string, mimeType: string): Promise<MenuExtractionResult>
|
||||
recommendDrinks(
|
||||
extractedItems: ExtractedMenuItem[],
|
||||
|
||||
Reference in New Issue
Block a user