Route all AI features through the Switchboard gateway

Replace the direct Anthropic and OpenAI integrations with a single
provider that talks to Switchboard, an OpenAI-compatible gateway that
routes each request to the best available model. The app no longer pins
a model id anywhere: it sends switchboard/auto and lets the gateway
choose, then logs which model answered and what it cost.

Routing levers are set per feature in src/lib/ai/routing.ts. Three of
those choices came from measuring against the live gateway:

- category and prefer_free are set explicitly on every request. An API
  key carries its own routing defaults, and anything left unset inherits
  them - drink prompts were being sent to a free coding model.
- Token budgets are generous because the router may pick a reasoning
  model, and reasoning tokens come out of the same max_tokens budget as
  the answer. At 512 tokens a request returned null content; at 4096 the
  same request returned correct JSON.
- No tier lever on text features. tier "cheap" pinned a slow reasoning
  model (42-180s, two timeouts and one truncated response in five
  trials) and tier "frontier" escalated as far as Opus at $0.02 a call,
  while unconstrained routing answered in about a second. Vision keeps
  "frontier", where the accuracy is worth a few tenths of a cent.

Gateway failures are mapped to actionable messages rather than passed
through: a 401 relayed as 401 would read as an expired session and
bounce the user to login, and a 429 would collide with the app's own
rate limiter.

Also collapses the key lookup that was duplicated across ten call sites
into getUserProvider(), which fixes a latent bug where a bare findFirst
with no ordering let different features pick different providers.

Existing claude/openai key rows are ignored at runtime and offered for
removal in Settings, so no migration is needed before deploying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JP
2026-08-08 16:41:00 +00:00
parent 7c41b15ecc
commit a0e1619072
31 changed files with 830 additions and 429 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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