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) {