From 34afc497f4f75424d1740002d8405fb5096e6567 Mon Sep 17 00:00:00 2001 From: JP Date: Sat, 8 Aug 2026 21:16:14 +0000 Subject: [PATCH] Give members their own budget-capped gateway key Members share the owner's AI budget, so each now gets a Switchboard key minted on first use with a daily cap and a per-request ceiling. That gives real spend limits and per-user attribution: the app's own rate limiter lives in memory and resets on every deploy, so it could never be a spend control. The gateway enforces the caps and answers 402 guardrail, which the error mapper already turns into a budget message. Resolution order is the user's own key, then mint, then borrow the owner's key for a single request if the gateway is unreachable - a served request beats a hard failure, and the fallback logs loudly because no per-user cap applies to it. The owner key is used only for minting, never for inference. API key management is now owner-only, enforced on GET, POST and DELETE. GET matters as much as POST because it returns the masked key and the gateway URL. Settings became a server component so the role is known before first render: members never see the card and never issue the request, rather than having it flash and disappear. AiCall records one row per request from the gateway's own response metadata, so member spend is queryable instead of a journald grep. The write is fire-and-forget - tracking must never fail a working request. Co-Authored-By: Claude Opus 5 (1M context) --- prisma/schema.prisma | 24 ++ src/app/(app)/settings/page.tsx | 426 +------------------- src/app/api/settings/api-keys/route.ts | 26 +- src/components/settings/settings-client.tsx | 426 ++++++++++++++++++++ src/lib/ai/provider-factory.ts | 79 +++- src/lib/ai/switchboard-keys.ts | 101 +++++ src/lib/ai/switchboard-log.ts | 29 ++ src/lib/ai/switchboard-provider.ts | 11 +- 8 files changed, 679 insertions(+), 443 deletions(-) create mode 100644 src/components/settings/settings-client.tsx create mode 100644 src/lib/ai/switchboard-keys.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1bd826b..eada22b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,6 +53,7 @@ model User { recipes Recipe[] flavorProfile FlavorProfile? searchCache SearchCache[] + aiCalls AiCall[] invitesCreated Invite[] @relation("InviteCreator") redemption InviteRedemption? @@ -155,6 +156,9 @@ model UserApiKey { encryptedKey String @db.Text iv String // initialization vector for decryption label String? // optional user-friendly label + // Gateway-side id of a key this app minted, so it can be revoked when the + // account is deleted. Null for a key the owner pasted in by hand. + gatewayKeyId String? isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -164,6 +168,26 @@ model UserApiKey { @@unique([userId, provider]) } +/// One row per AI request, written from the gateway's response metadata. Turns +/// "who is spending my money" into a query rather than a journald grep, now that +/// members share the owner's budget. +model AiCall { + id String @id @default(cuid()) + userId String + feature String // e.g. "menu.extract" + modelId String? // model the gateway actually routed to + provider String? + costUsd Float? + latencyMs Int? + failover Boolean @default(false) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([createdAt]) +} + model UserPreference { id String @id @default(cuid()) userId String @unique diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index f4d48af..d627497 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -1,421 +1,15 @@ -"use client" - -import { useState } from "react" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { Header } from "@/components/layout/header" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { Key, Trash2, Check, Loader2, Shield, Sliders } from "lucide-react" -import { BackupRestore } from "@/components/settings/backup-restore" - -interface ApiKeyInfo { - id: string - provider: string - label?: string - maskedKey: string - isActive: boolean -} - -interface ApiKeysResponse { - keys: ApiKeyInfo[] - gatewayUrl: string -} - -export default function SettingsPage() { - const queryClient = useQueryClient() - - // API Keys - const { data: apiKeyData } = useQuery({ - queryKey: ["api-keys"], - queryFn: async () => { - const res = await fetch("/api/settings/api-keys") - if (!res.ok) throw new Error("Failed to fetch API keys") - return res.json() - }, - }) - - const apiKeys = apiKeyData?.keys ?? [] - const legacyKeys = apiKeys.filter( - (k) => k.provider === "claude" || k.provider === "openai" - ) - - // Preferences - const { data: preferences, isLoading: prefsLoading } = useQuery({ - queryKey: ["preferences"], - queryFn: async () => { - const res = await fetch("/api/settings/preferences") - if (!res.ok) throw new Error("Failed to fetch preferences") - return res.json() - }, - }) - - const savePreferences = useMutation({ - mutationFn: async (prefs: Record) => { - const res = await fetch("/api/settings/preferences", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(prefs), - }) - if (!res.ok) throw new Error("Failed to save preferences") - return res.json() - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["preferences"] }) - }, - }) - - return ( -
-
-
-
-

Settings

-

- Manage your AI providers and preferences -

-
- - {/* API Keys Section */} - - - - - AI Gateway - - - AI features route through Switchboard, which picks the best model for each - request. Add your gateway API key below — it is encrypted before storage. - {apiKeyData?.gatewayUrl && ( - <> - {" "} - This app is pointed at{" "} - {apiKeyData.gatewayUrl}. - - )} - - - - k.provider === "switchboard")} - /> - {legacyKeys.length > 0 && ( - <> - - - - )} - - - - {/* Preferences Section */} - - - - - Drink Preferences - - - Help the AI make better recommendations by telling it what you like - - - - savePreferences.mutate(prefs)} - isSaving={savePreferences.isPending} - /> - - - - {/* Backup & Restore Section */} - -
-
- ) -} - -const LEGACY_PROVIDER_LABELS: Record = { - claude: "Anthropic Claude", - openai: "OpenAI", -} +import { redirect } from "next/navigation" +import { auth } from "@/lib/auth" +import { SettingsClient } from "@/components/settings/settings-client" /** - * 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. + * Server component so the role is known before the first render. Passing isOwner + * down avoids the flash of an AI Gateway card that useSession() alone would give, + * and lets the client skip the owner-only key request entirely. */ -function LegacyKeyNotice({ keys }: { keys: ApiKeyInfo[] }) { - const queryClient = useQueryClient() +export default async function SettingsPage() { + const session = await auth() + if (!session?.user?.id) redirect("/login") - const deleteKey = useMutation({ - mutationFn: async (provider: string) => { - const res = await fetch(`/api/settings/api-keys?provider=${provider}`, { - method: "DELETE", - }) - if (!res.ok) throw new Error("Failed to delete API key") - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["api-keys"] }) - }, - }) - - return ( -
-

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

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

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

- - {key.maskedKey} - -
- -
- ))} -
- ) -} - -function ApiKeyForm({ - provider, - label, - existingKey, -}: { - provider: string - label: string - existingKey?: ApiKeyInfo -}) { - const [apiKey, setApiKey] = useState("") - const [isEditing, setIsEditing] = useState(false) - const queryClient = useQueryClient() - - const saveKey = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/settings/api-keys", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, apiKey }), - }) - if (!res.ok) throw new Error("Failed to save API key") - return res.json() - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["api-keys"] }) - setApiKey("") - setIsEditing(false) - }, - }) - - const deleteKey = useMutation({ - mutationFn: async () => { - 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"] }) - }, - }) - - if (existingKey && !isEditing) { - return ( -
-
-

{label}

-
- - {existingKey.maskedKey} - - - - Active - -
-
-
- - -
-
- ) - } - - return ( -
-

{label}

-
- setApiKey(e.target.value)} - /> - - {isEditing && ( - - )} -
-

- - Your key is encrypted before storage and never exposed in full -

-
- ) -} - -function PreferencesForm({ - preferences, - isLoading, - onSave, - isSaving, -}: { - preferences: Record | undefined - isLoading: boolean - onSave: (prefs: Record) => void - isSaving: boolean -}) { - const [preferredStyles, setPreferredStyles] = useState("") - const [avoidedStyles, setAvoidedStyles] = useState("") - const [minAbv, setMinAbv] = useState("") - const [maxAbv, setMaxAbv] = useState("") - const [initialized, setInitialized] = useState(false) - - if (preferences && !initialized) { - const prefs = preferences as { preferredStyles?: string[]; avoidedStyles?: string[]; minAbv?: number; maxAbv?: number } - setPreferredStyles(prefs.preferredStyles?.join(", ") || "") - setAvoidedStyles(prefs.avoidedStyles?.join(", ") || "") - setMinAbv(prefs.minAbv?.toString() || "") - setMaxAbv(prefs.maxAbv?.toString() || "") - setInitialized(true) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - onSave({ - preferredStyles: preferredStyles - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - avoidedStyles: avoidedStyles - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - minAbv: minAbv ? parseFloat(minAbv) : null, - maxAbv: maxAbv ? parseFloat(maxAbv) : null, - }) - } - - if (isLoading) return

Loading...

- - return ( -
-
- - setPreferredStyles(e.target.value)} - /> -

- Comma-separated list of styles you enjoy -

-
- -
- - setAvoidedStyles(e.target.value)} - /> -

- Comma-separated list of styles you want to avoid -

-
- -
-
- - setMinAbv(e.target.value)} - /> -
-
- - setMaxAbv(e.target.value)} - /> -
-
- - -
- ) + return } diff --git a/src/app/api/settings/api-keys/route.ts b/src/app/api/settings/api-keys/route.ts index 813d398..e2fb5cc 100644 --- a/src/app/api/settings/api-keys/route.ts +++ b/src/app/api/settings/api-keys/route.ts @@ -1,15 +1,15 @@ import { NextResponse } from "next/server" -import { auth } from "@/lib/auth" +import { requireOwner } from "@/lib/authz" import { prisma } from "@/lib/prisma" import { encrypt, decrypt, maskApiKey } from "@/lib/encryption" import { apiKeySchema } from "@/lib/validators" import { switchboardBaseUrl } from "@/lib/ai/switchboard-provider" export async function GET() { - const session = await auth() - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + // Owner only: members share the owner's gateway budget via a minted key and never + // manage one themselves. GET matters as much as POST - it returns the masked key. + const session = await requireOwner() + if (session instanceof NextResponse) return session const apiKeys = await prisma.userApiKey.findMany({ where: { userId: session.user.id }, @@ -51,10 +51,10 @@ export async function GET() { } export async function POST(request: Request) { - const session = await auth() - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + // Owner only: members share the owner's gateway budget via a minted key and never + // manage one themselves. GET matters as much as POST - it returns the masked key. + const session = await requireOwner() + if (session instanceof NextResponse) return session try { const body = await request.json() @@ -119,10 +119,10 @@ export async function POST(request: Request) { } export async function DELETE(request: Request) { - const session = await auth() - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } + // Owner only: members share the owner's gateway budget via a minted key and never + // manage one themselves. GET matters as much as POST - it returns the masked key. + const session = await requireOwner() + if (session instanceof NextResponse) return session const { searchParams } = new URL(request.url) const provider = searchParams.get("provider") diff --git a/src/components/settings/settings-client.tsx b/src/components/settings/settings-client.tsx new file mode 100644 index 0000000..209d2cb --- /dev/null +++ b/src/components/settings/settings-client.tsx @@ -0,0 +1,426 @@ +"use client" + +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Header } from "@/components/layout/header" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { Key, Trash2, Check, Loader2, Shield, Sliders } from "lucide-react" +import { BackupRestore } from "@/components/settings/backup-restore" + +interface ApiKeyInfo { + id: string + provider: string + label?: string + maskedKey: string + isActive: boolean +} + +interface ApiKeysResponse { + keys: ApiKeyInfo[] + gatewayUrl: string +} + +export function SettingsClient({ isOwner }: { isOwner: boolean }) { + const queryClient = useQueryClient() + + // API Keys + const { data: apiKeyData } = useQuery({ + queryKey: ["api-keys"], + queryFn: async () => { + const res = await fetch("/api/settings/api-keys") + if (!res.ok) throw new Error("Failed to fetch API keys") + return res.json() + }, + enabled: isOwner, + }) + + const apiKeys = apiKeyData?.keys ?? [] + const legacyKeys = apiKeys.filter( + (k) => k.provider === "claude" || k.provider === "openai" + ) + + // Preferences + const { data: preferences, isLoading: prefsLoading } = useQuery({ + queryKey: ["preferences"], + queryFn: async () => { + const res = await fetch("/api/settings/preferences") + if (!res.ok) throw new Error("Failed to fetch preferences") + return res.json() + }, + }) + + const savePreferences = useMutation({ + mutationFn: async (prefs: Record) => { + const res = await fetch("/api/settings/preferences", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(prefs), + }) + if (!res.ok) throw new Error("Failed to save preferences") + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["preferences"] }) + }, + }) + + return ( +
+
+
+
+

Settings

+

+ Manage your AI providers and preferences +

+
+ + {/* API Keys Section - owner only. Members use a gateway key minted for + them with a daily budget, and never manage one. The api-keys route + enforces this server-side; hiding it here is just presentation. */} + {isOwner && ( + + + + + AI Gateway + + + AI features route through Switchboard, which picks the best model for each + request. Add your gateway API key below — it is encrypted before storage. + {apiKeyData?.gatewayUrl && ( + <> + {" "} + This app is pointed at{" "} + {apiKeyData.gatewayUrl}. + + )} + + + + k.provider === "switchboard")} + /> + {legacyKeys.length > 0 && ( + <> + + + + )} + + + )} + + {/* Preferences Section */} + + + + + Drink Preferences + + + Help the AI make better recommendations by telling it what you like + + + + savePreferences.mutate(prefs)} + isSaving={savePreferences.isPending} + /> + + + + {/* Backup & Restore Section */} + +
+
+ ) +} + +const LEGACY_PROVIDER_LABELS: Record = { + claude: "Anthropic Claude", + openai: "OpenAI", +} + +/** + * Keys left over from when the app called Claude and OpenAI directly. They are + * already ignored when picking a provider, but they are shown here so a user who + * still has one can see it is inert and remove it. + */ +function LegacyKeyNotice({ keys }: { keys: ApiKeyInfo[] }) { + const queryClient = useQueryClient() + + const deleteKey = useMutation({ + mutationFn: async (provider: string) => { + const res = await fetch(`/api/settings/api-keys?provider=${provider}`, { + method: "DELETE", + }) + if (!res.ok) throw new Error("Failed to delete API key") + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["api-keys"] }) + }, + }) + + return ( +
+

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

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

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

+ + {key.maskedKey} + +
+ +
+ ))} +
+ ) +} + +function ApiKeyForm({ + provider, + label, + existingKey, +}: { + provider: string + label: string + existingKey?: ApiKeyInfo +}) { + const [apiKey, setApiKey] = useState("") + const [isEditing, setIsEditing] = useState(false) + const queryClient = useQueryClient() + + const saveKey = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/settings/api-keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, apiKey }), + }) + if (!res.ok) throw new Error("Failed to save API key") + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["api-keys"] }) + setApiKey("") + setIsEditing(false) + }, + }) + + const deleteKey = useMutation({ + mutationFn: async () => { + 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"] }) + }, + }) + + if (existingKey && !isEditing) { + return ( +
+
+

{label}

+
+ + {existingKey.maskedKey} + + + + Active + +
+
+
+ + +
+
+ ) + } + + return ( +
+

{label}

+
+ setApiKey(e.target.value)} + /> + + {isEditing && ( + + )} +
+

+ + Your key is encrypted before storage and never exposed in full +

+
+ ) +} + +function PreferencesForm({ + preferences, + isLoading, + onSave, + isSaving, +}: { + preferences: Record | undefined + isLoading: boolean + onSave: (prefs: Record) => void + isSaving: boolean +}) { + const [preferredStyles, setPreferredStyles] = useState("") + const [avoidedStyles, setAvoidedStyles] = useState("") + const [minAbv, setMinAbv] = useState("") + const [maxAbv, setMaxAbv] = useState("") + const [initialized, setInitialized] = useState(false) + + if (preferences && !initialized) { + const prefs = preferences as { preferredStyles?: string[]; avoidedStyles?: string[]; minAbv?: number; maxAbv?: number } + setPreferredStyles(prefs.preferredStyles?.join(", ") || "") + setAvoidedStyles(prefs.avoidedStyles?.join(", ") || "") + setMinAbv(prefs.minAbv?.toString() || "") + setMaxAbv(prefs.maxAbv?.toString() || "") + setInitialized(true) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSave({ + preferredStyles: preferredStyles + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + avoidedStyles: avoidedStyles + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + minAbv: minAbv ? parseFloat(minAbv) : null, + maxAbv: maxAbv ? parseFloat(maxAbv) : null, + }) + } + + if (isLoading) return

Loading...

+ + return ( +
+
+ + setPreferredStyles(e.target.value)} + /> +

+ Comma-separated list of styles you enjoy +

+
+ +
+ + setAvoidedStyles(e.target.value)} + /> +

+ Comma-separated list of styles you want to avoid +

+
+ +
+
+ + setMinAbv(e.target.value)} + /> +
+
+ + setMaxAbv(e.target.value)} + /> +
+
+ + +
+ ) +} diff --git a/src/lib/ai/provider-factory.ts b/src/lib/ai/provider-factory.ts index 98f1dff..0e3506c 100644 --- a/src/lib/ai/provider-factory.ts +++ b/src/lib/ai/provider-factory.ts @@ -1,8 +1,9 @@ import type { AIProvider } from "./types" import { SwitchboardProvider } from "./switchboard-provider" import { AIGatewayError } from "./errors" +import { mintMemberKey, ownerGatewayKey } from "./switchboard-keys" import { prisma } from "@/lib/prisma" -import { decrypt } from "@/lib/encryption" +import { decrypt, encrypt } from "@/lib/encryption" /** The only provider this app uses. Also the `provider` value stored on UserApiKey. */ export const AI_PROVIDER = "switchboard" as const @@ -10,6 +11,9 @@ export const AI_PROVIDER = "switchboard" as const export const NO_KEY_MESSAGE = "No Switchboard API key configured. Add one in Settings." +export const AI_DISABLED_MESSAGE = + "AI features have been turned off for your account. Contact the owner." + export function createProvider(providerName: string, apiKey: string): AIProvider { if (providerName === AI_PROVIDER) return new SwitchboardProvider(apiKey) throw new Error( @@ -18,24 +22,75 @@ export function createProvider(providerName: string, apiKey: string): AIProvider } /** - * Single source of truth for "give me this user's configured AI provider". + * Resolve the gateway key to use for a request, in order: * - * 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. + * 1. The user's own active key. Unchanged for the owner, and minted member keys + * land here too, so the encryption path and every call site stay the same. + * 2. Mint one for a member, capped to their daily budget. Lazy and idempotent - + * the unique constraint on (userId, provider) makes the upsert safe. + * 3. The owner's key, for this one request, if minting is unavailable. Better a + * served request than a hard failure when the gateway is briefly down. + * + * Filtering on `provider` also ignores legacy claude/openai rows, which hold vendor + * keys the gateway would reject. */ -export async function getUserProvider( - userId: string -): Promise { - const record = await prisma.userApiKey.findFirst({ +async function resolveApiKey(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { role: true, aiEnabled: true, aiDailyBudgetUsd: true }, + }) + if (!user) throw new AIGatewayError("no such user", 401, "Unauthorized", "no_key") + + if (!user.aiEnabled) { + throw new AIGatewayError("ai disabled", 403, AI_DISABLED_MESSAGE, "no_key") + } + + const existing = await prisma.userApiKey.findFirst({ where: { userId, isActive: true, provider: AI_PROVIDER }, orderBy: { updatedAt: "desc" }, }) + if (existing) return decrypt(existing.encryptedKey, existing.iv) - if (!record) { + // The owner is expected to supply their own key in Settings; minting one for them + // from their own owner key would be circular. + if (user.role === "OWNER") { throw new AIGatewayError("no api key", 400, NO_KEY_MESSAGE, "no_key") } - return new SwitchboardProvider(decrypt(record.encryptedKey, record.iv)) + const minted = await mintMemberKey(userId, user.aiDailyBudgetUsd) + if (minted) { + const { encrypted, iv } = encrypt(minted.key) + await prisma.userApiKey.upsert({ + where: { userId_provider: { userId, provider: AI_PROVIDER } }, + update: { encryptedKey: encrypted, iv, gatewayKeyId: minted.id, isActive: true }, + create: { + userId, + provider: AI_PROVIDER, + encryptedKey: encrypted, + iv, + gatewayKeyId: minted.id, + label: "auto", + isActive: true, + }, + }) + return minted.key + } + + // Minting failed. Borrow the owner's key so the feature still works, and make it + // loud - a member on the owner's key has no per-user budget cap. + const fallback = ownerGatewayKey() + if (fallback) { + console.warn( + `[switchboard] minting unavailable for ${userId}; using the owner key for this request (no per-user budget applies)` + ) + return fallback + } + + throw new AIGatewayError("no api key", 400, NO_KEY_MESSAGE, "no_key") +} + +/** Single source of truth for "give me this user's configured AI provider". */ +export async function getUserProvider(userId: string): Promise { + const apiKey = await resolveApiKey(userId) + return new SwitchboardProvider(apiKey, userId) } diff --git a/src/lib/ai/switchboard-keys.ts b/src/lib/ai/switchboard-keys.ts new file mode 100644 index 0000000..cf5b0b3 --- /dev/null +++ b/src/lib/ai/switchboard-keys.ts @@ -0,0 +1,101 @@ +import { switchboardBaseUrl } from "./switchboard-provider" + +/** + * Mints per-member gateway keys. + * + * Members share the owner's AI budget, so each gets their own Switchboard key with a + * daily cap rather than everyone using one key. That gives per-user attribution and, + * more importantly, a real spend limit: the app's own rate limiter lives in memory and + * resets on every deploy, so it can never be a spend control. The gateway enforces + * these caps itself and answers 402 {code:"guardrail"} when one is hit, which + * toAIGatewayError already maps to a budget message. + * + * The owner key is only ever used here. Inference always uses a per-user key. + */ + +/** Default daily cap for a member with no explicit budget set. */ +export const DEFAULT_MEMBER_DAILY_USD = 1.0 + +/** Ceiling for any single request, so one prompt cannot spend the whole day's budget. */ +export const DEFAULT_MAX_COST_PER_REQUEST_USD = 0.25 + +export function ownerGatewayKey(): string | null { + return process.env.SWITCHBOARD_OWNER_KEY || null +} + +export interface MintedKey { + id: string + key: string +} + +/** + * Create a gateway key for a member. Returns null if no owner key is configured or the + * gateway refuses - callers fall back rather than failing the user's request outright. + * + * Verified against the gateway: POST /v1/keys takes {label, limits:{dailyBudgetUsd, + * maxCostPerRequest}} and returns {id, key, ...}. Key management is owner-key-only; + * a managed key gets 403. + */ +export async function mintMemberKey( + userId: string, + dailyBudgetUsd: number | null +): Promise { + const owner = ownerGatewayKey() + if (!owner) { + console.warn("[switchboard] SWITCHBOARD_OWNER_KEY not set - cannot mint member keys") + return null + } + + try { + const response = await fetch(`${switchboardBaseUrl()}/keys`, { + method: "POST", + headers: { + Authorization: `Bearer ${owner}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + // Label carries the user id so a key can be traced back from the gateway UI. + label: `drinktracker:${userId}`, + limits: { + dailyBudgetUsd: dailyBudgetUsd ?? DEFAULT_MEMBER_DAILY_USD, + maxCostPerRequest: DEFAULT_MAX_COST_PER_REQUEST_USD, + }, + }), + signal: AbortSignal.timeout(15000), + }) + + if (!response.ok) { + console.warn(`[switchboard] mint failed for ${userId}: HTTP ${response.status}`) + return null + } + + const body = (await response.json()) as { id?: string; key?: string } + if (!body.key || !body.id) { + console.warn(`[switchboard] mint returned no key for ${userId}`) + return null + } + + console.log(`[switchboard] minted gateway key ${body.id} for user ${userId}`) + return { id: body.id, key: body.key } + } catch (error) { + console.warn(`[switchboard] mint threw for ${userId}:`, error) + return null + } +} + +/** Best-effort revocation, used when an account is deleted. */ +export async function revokeGatewayKey(keyId: string): Promise { + const owner = ownerGatewayKey() + if (!owner) return false + + try { + const response = await fetch(`${switchboardBaseUrl()}/keys/${keyId}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${owner}` }, + signal: AbortSignal.timeout(15000), + }) + return response.ok + } catch { + return false + } +} diff --git a/src/lib/ai/switchboard-log.ts b/src/lib/ai/switchboard-log.ts index b8f48a7..ee2c022 100644 --- a/src/lib/ai/switchboard-log.ts +++ b/src/lib/ai/switchboard-log.ts @@ -1,4 +1,33 @@ import type { SwitchboardMeta } from "./switchboard-types" +import { prisma } from "@/lib/prisma" + +/** + * Persist one row per AI call so member spend is queryable. + * + * Deliberately not awaited by callers: an insert failure must never turn a working + * AI response into an error for the user. Failures are logged and dropped. + */ +export function recordAiCall( + userId: string, + feature: string, + meta: SwitchboardMeta | null +): void { + void prisma.aiCall + .create({ + data: { + userId, + feature, + modelId: meta?.model_id ?? null, + provider: meta?.provider ?? null, + costUsd: meta?.cost_usd ?? null, + latencyMs: meta?.latency_ms ?? null, + failover: meta?.failover ?? false, + }, + }) + .catch((error) => { + console.warn("[switchboard] failed to record ai call:", error) + }) +} /** * One line per gateway call so the cost and the model actually used are visible in diff --git a/src/lib/ai/switchboard-provider.ts b/src/lib/ai/switchboard-provider.ts index c142d16..025f8a8 100644 --- a/src/lib/ai/switchboard-provider.ts +++ b/src/lib/ai/switchboard-provider.ts @@ -5,7 +5,7 @@ import { type FeatureRouting, type SwitchboardMeta, } from "./switchboard-types" -import { logSwitchboardMeta } from "./switchboard-log" +import { logSwitchboardMeta, recordAiCall } from "./switchboard-log" type ChatParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion @@ -41,9 +41,12 @@ export class SwitchboardProvider extends BaseAIProvider { lastMeta: SwitchboardMeta | null = null private client: OpenAI + /** Set when the provider was built for a known user, so calls can be attributed. */ + private userId?: string - constructor(apiKey: string) { + constructor(apiKey: string, userId?: string) { super() + this.userId = userId this.client = new OpenAI({ apiKey, baseURL: switchboardBaseUrl(), @@ -124,6 +127,10 @@ export class SwitchboardProvider extends BaseAIProvider { this.lastMeta = readSwitchboardMeta(completion) logSwitchboardMeta(feature, this.lastMeta) + // Fire and forget: spend tracking must never fail a user's request. + if (this.userId) { + recordAiCall(this.userId, feature, this.lastMeta) + } const message = completion.choices?.[0]?.message?.content if (!message) {