diff --git a/.env.example b/.env.example index 229d0120d..bb46947fb 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,13 @@ APP_LOCALE=de-CH # Docker only — Postgres password (must match DATABASE_URL above) POSTGRES_PASSWORD=changeme +# "Power Loki with your own model" (Settings → AI). Seals each user's API key +# at rest (@bitbaum/ai-kit/seal, AES-256-GCM). Without it the feature says it +# is not switched on and chat keeps using the free chain. Generate once with +# `openssl rand -base64 32`; ROTATING it makes every stored key unreadable +# (users are then asked to paste their key again). +BYOK_SEAL_SECRET= + # ── Brand alias scheme ─────────────────────────────────────────────────────── # Every env var below is read as APP_ first, then COCKPIT_ as a # legacy fallback (mirror of scripts/_brand.sh:_brand_env). Set whichever diff --git a/drizzle/0081_user_model_keys.sql b/drizzle/0081_user_model_keys.sql new file mode 100644 index 000000000..f01add4e8 --- /dev/null +++ b/drizzle/0081_user_model_keys.sql @@ -0,0 +1,26 @@ +-- Migration: user_model_keys — the model a user brings to power Loki. +-- +-- WHY THIS EXISTS +-- Loki's own chat ran only on the shared, rationed free chain: when the day's +-- budget ran out a user was told to wait, and there was no way to say "use my +-- Anthropic / OpenAI / OpenRouter / … key instead". This stores that choice: +-- a vendor from ai-kit's closed list, the model picked there, and the key. +-- +-- THE KEY IS STORED SEALED (ai-kit/seal: AES-256-GCM, scrypt-derived from the +-- app's BYOK_SEAL_SECRET, `iv:tag:ciphertext`). The database alone cannot read +-- it, no API ever returns it, and `key_hint` ("…abcd") is the only part shown. +-- One row per user; removing the user removes the key. +-- +-- Hand-written, like 0073–0080: drizzle/meta's snapshot stops at 0072, so +-- `drizzle-kit generate` diffs against a stale picture and asks whether this +-- table is a rename of beacon_sessions. +CREATE TABLE IF NOT EXISTS user_model_keys ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + vendor text NOT NULL, + model text NOT NULL, + sealed_key text NOT NULL, + key_hint text NOT NULL, + verified_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1cc5319ae..e0bb37eab 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -568,6 +568,13 @@ "when": 1767225680000, "tag": "0080_project_invitations", "breakpoints": true + }, + { + "idx": 81, + "version": "7", + "when": 1767225681000, + "tag": "0081_user_model_keys", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 87cd435de..02f8f6d4a 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "dependencies": { "@auth/core": "0.41.3", "@auth/drizzle-adapter": "^1.11.3", - "@bitbaum/ai-kit": "^1.11.0", + "@bitbaum/ai-kit": "^1.17.0", "@bitbaum/chatkit": "github:bitbaum/chatkit#v0.1.0", "@bitbaum/mail-kit": "^0.1.1", "@dnd-kit/core": "^6.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 021e25b97..d317d434f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: ^1.11.3 version: 1.11.3 '@bitbaum/ai-kit': - specifier: ^1.11.0 - version: 1.11.0(react@19.2.8) + specifier: ^1.17.0 + version: 1.17.0(react@19.2.8) '@bitbaum/chatkit': specifier: github:bitbaum/chatkit#v0.1.0 version: https://codeload.github.com/bitbaum/chatkit/tar.gz/ed759a8516113d40aefee536d993b4fa04ef1e05(react@19.2.8) @@ -379,8 +379,8 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@bitbaum/ai-kit@1.11.0': - resolution: {integrity: sha512-WNprP0kydOfxyFz4itnkbc1Khu2R7CP+z4809usXKFukc+xEiooKT1qKnf0RWu0ti4CIFikz3BluoNlF1Di83w==} + '@bitbaum/ai-kit@1.17.0': + resolution: {integrity: sha512-nd3qktLwDCdCl7F4Lws7srJDmajB+8y+8BTmQ92yKpFDUXTM1k7f5MS5n5ZiZ6ooKXgjX56wOgp/Ezm+0wc4/g==} engines: {node: '>=20'} peerDependencies: react: '>=18' @@ -5223,7 +5223,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@bitbaum/ai-kit@1.11.0(react@19.2.8)': + '@bitbaum/ai-kit@1.17.0(react@19.2.8)': dependencies: ai-forms: 0.1.2(react@19.2.8) optionalDependencies: diff --git a/scripts/test/own-model.ts b/scripts/test/own-model.ts new file mode 100644 index 000000000..46de06adc --- /dev/null +++ b/scripts/test/own-model.ts @@ -0,0 +1,142 @@ +/** + * A user's own model: their key goes to their vendor, and only there. + * + * THE PROPERTIES THIS PINS + * ------------------------ + * Loki's chat can run on a key the user brought (Settings → AI). The walker + * that makes the call looks a link's key up BY NAME, so the whole safety of + * the feature rests on one rule: a user's link reads its key only from the + * per-call env ai-kit's byokChain returns, and a server link reads only + * `process.env`. Break it either way and one of two things happens: + * + * • a deployment that happens to set BYOK_API_KEY answers every user's turn + * on the operator's account, or + * • a user's key rides along on a server link to a vendor they never chose. + * + * The test drives the REAL walker (`callModelWithTools`) with a fake fetch and + * reads the request it actually sends. + * + * Run: npx tsx scripts/test/own-model.ts + */ +import assert from "node:assert/strict"; +import { byokChain } from "@bitbaum/ai-kit/byok"; +import { callModelWithTools } from "@/lib/agent/llm"; +import { OWN_MODEL_KEY_ENV, isOwnModelLink, keyForLink, ownModelFrom } from "@/lib/own-model"; +import type { ChatLink } from "@/config/chat-models"; + +let passed = 0; +async function check(name: string, fn: () => void | Promise) { + await fn(); + passed++; + console.log(` ✓ ${name}`); +} + +const USER_KEY = "sk-ant-test-not-a-real-key-1234"; +const config = { vendor: "anthropic" as const, apiKey: USER_KEY, model: "claude-opus-5.5" }; + +async function main() { + await check("the key name here is the one ai-kit's byokChain actually uses", () => { + const { chain, env } = byokChain(config); + assert.equal(chain[0]!.provider.keyEnv, OWN_MODEL_KEY_ENV); + assert.equal((env as Record)[OWN_MODEL_KEY_ENV], USER_KEY); + }); + + await check("a user's link never falls back to a key the server happens to hold", () => { + const own = ownModelFrom(config); + const link = own.chain[0]!; + process.env[OWN_MODEL_KEY_ENV] = "server-set-this-by-accident"; + try { + assert.equal(keyForLink(link, own), USER_KEY); + assert.equal( + keyForLink(link, undefined), + undefined, + "no per-call env → no key, never process.env", + ); + } finally { + delete process.env[OWN_MODEL_KEY_ENV]; + } + }); + + await check("a server link never takes a key a user supplied", () => { + const serverLink = { + provider: { + id: "groq", + baseUrl: "https://api.groq.com/openai/v1", + keyEnv: "GROQ_API_KEY", + models: ["m"], + }, + model: "m", + } as unknown as ChatLink; + const hostile = { env: { GROQ_API_KEY: "user-injected" } }; + process.env.GROQ_API_KEY = "the-server-groq-key"; + try { + assert.equal(isOwnModelLink(serverLink), false); + assert.equal(keyForLink(serverLink, hostile), "the-server-groq-key"); + } finally { + delete process.env.GROQ_API_KEY; + } + }); + + await check( + "a chat turn on the user's model sends ONE request, to their vendor, with their key and model", + async () => { + const own = ownModelFrom(config); + const sent: { url: string; auth: string | undefined; model: unknown; allHeaders: string }[] = + []; + const realFetch = globalThis.fetch; + process.env.GROQ_API_KEY = "the-server-groq-key"; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + sent.push({ + url: String(url), + auth: headers.Authorization, + model: JSON.parse(String(init?.body ?? "{}")).model, + allHeaders: JSON.stringify(headers), + }); + return new Response( + JSON.stringify({ + choices: [{ message: { role: "assistant", content: "Hello from your model." } }], + usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + try { + const turn = await callModelWithTools({ + messages: [{ role: "user", content: "hi" }], + tools: [], + validToolNames: [], + feature: "own-model-test", + own, + }); + assert.equal(sent.length, 1, `expected one request, got ${sent.length}`); + assert.equal(sent[0]!.url, "https://api.anthropic.com/v1/chat/completions"); + assert.equal(sent[0]!.auth, `Bearer ${USER_KEY}`); + assert.equal(sent[0]!.model, "claude-opus-5.5"); + assert.ok(!sent[0]!.allHeaders.includes("the-server-groq-key"), "a server key rode along"); + assert.match(JSON.stringify(turn), /Hello from your model/); + } finally { + globalThis.fetch = realFetch; + delete process.env.GROQ_API_KEY; + } + }, + ); + + await check("OpenRouter gets its attribution headers; a direct lab does not", () => { + assert.ok( + ownModelFrom({ ...config, vendor: "openrouter", model: "anthropic/claude-opus-5.5" }) + .extraHeaders, + ); + assert.equal(ownModelFrom(config).extraHeaders, undefined); + }); + + await check("the label names the vendor and model, never the key", () => { + const label = ownModelFrom(config).label; + assert.match(label, /Anthropic · claude-opus-5\.5/); + assert.ok(!label.includes(USER_KEY)); + }); + + console.log(`\nown-model: ${passed} passed`); +} + +void main(); diff --git a/src/app/api/settings/model/probe/route.ts b/src/app/api/settings/model/probe/route.ts new file mode 100644 index 000000000..83e7eb5f3 --- /dev/null +++ b/src/app/api/settings/model/probe/route.ts @@ -0,0 +1,55 @@ +/** + * POST { vendor, apiKey? } → "does this key work, and what can it use?" + * + * Nothing is stored. The settings screen calls this the moment a key is + * pasted, so it can say whether the key works and offer the models it can + * reach with the best one preselected (ai-kit's probeByokKey). With no apiKey + * it probes the key already stored for that vendor — so changing model never + * means pasting the key again. + * + * Server-side because vendors refuse browser-origin calls, and because the + * hosts it may contact are ai-kit's closed list, never a URL from the request. + * Rate-limited per user: it makes one or two outbound calls per request. + */ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { BYOK_VENDOR_IDS, type ByokVendorId } from "@bitbaum/ai-kit/byok"; +import { probeByokKey } from "@bitbaum/ai-kit/byok-probe"; +import { getApiUserId } from "@/lib/session"; +import { jsonError, jsonOk, readJsonBody, z } from "@/lib/api/route-helpers"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getOwnModel } from "@/db/queries/user-model-keys"; + +export const runtime = "nodejs"; + +const Body = z.object({ + vendor: z.enum(BYOK_VENDOR_IDS as [ByokVendorId, ...ByokVendorId[]]), + apiKey: z.string().trim().min(8).max(400).optional(), +}); + +export async function POST(req: NextRequest) { + const userId = await getApiUserId(); + if (!userId) return jsonError("Unauthorized", 401); + if (!checkRateLimit(`own-model:${userId}`, 20, 10 * 60_000)) { + return jsonError("Too many attempts — wait a few minutes and try again.", 429); + } + const body = await readJsonBody(req, Body); + if (body instanceof NextResponse) return body; + + let apiKey = body.apiKey; + if (!apiKey) { + const stored = await getOwnModel(userId); + if (!stored || stored.vendor !== body.vendor) { + return jsonError("Paste your key for this provider first.", 400); + } + apiKey = stored.apiKey; + } + + const probe = await probeByokKey(body.vendor, apiKey); + return jsonOk({ + works: probe.ok, + message: probe.message, + models: probe.models, + suggested: probe.suggested, + }); +} diff --git a/src/app/api/settings/model/route.ts b/src/app/api/settings/model/route.ts new file mode 100644 index 000000000..a5cadab68 --- /dev/null +++ b/src/app/api/settings/model/route.ts @@ -0,0 +1,103 @@ +/** + * The model a user brings to power Loki — read, save, change, remove. + * + * GET → { available, current: { vendor, model, keyHint, verifiedAt } | null } + * PUT { vendor, model, apiKey? } → saves; apiKey omitted = change model, keep key + * DELETE → forgets the key + * + * The key is never returned by any method; `keyHint` ("…abcd") is all a client + * ever sees. Checking a key WITHOUT saving it is ./probe. + */ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { BYOK_VENDOR_IDS, isByokConfig, type ByokVendorId } from "@bitbaum/ai-kit/byok"; +import { probeByokKey } from "@bitbaum/ai-kit/byok-probe"; +import { getApiUserId } from "@/lib/session"; +import { jsonError, jsonOk, readJsonBody, z } from "@/lib/api/route-helpers"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + deleteOwnModel, + getOwnModel, + getOwnModelSummary, + ownModelSealSecret, + saveOwnModel, + setOwnModelChoice, + type OwnModelSummary, +} from "@/db/queries/user-model-keys"; + +export const runtime = "nodejs"; + +const NOT_SET_UP = + "Connecting your own model isn't switched on for this server yet — Loki keeps using its free models."; + +function view(summary: OwnModelSummary | null) { + return summary + ? { + vendor: summary.vendor, + model: summary.model, + keyHint: summary.keyHint, + verifiedAt: summary.verifiedAt.toISOString(), + } + : null; +} + +export async function GET() { + const userId = await getApiUserId(); + if (!userId) return jsonError("Unauthorized", 401); + return jsonOk({ + available: ownModelSealSecret() !== null, + current: view(await getOwnModelSummary(userId)), + }); +} + +const PutBody = z.object({ + vendor: z.enum(BYOK_VENDOR_IDS as [ByokVendorId, ...ByokVendorId[]]), + model: z.string().trim().min(1).max(200), + apiKey: z.string().trim().min(8).max(400).optional(), +}); + +export async function PUT(req: NextRequest) { + const userId = await getApiUserId(); + if (!userId) return jsonError("Unauthorized", 401); + if (!ownModelSealSecret()) return jsonError(NOT_SET_UP, 503); + // Saving probes the vendor, so it shares the probe's allowance. + if (!checkRateLimit(`own-model:${userId}`, 20, 10 * 60_000)) { + return jsonError("Too many attempts — wait a few minutes and try again.", 429); + } + const body = await readJsonBody(req, PutBody); + if (body instanceof NextResponse) return body; + + // Change the model on the key already stored: no key in the request. + if (!body.apiKey) { + const stored = await getOwnModel(userId); + if (!stored || stored.vendor !== body.vendor) { + return jsonError("Paste your key for this provider first.", 400); + } + const probe = await probeByokKey(stored.vendor, stored.apiKey); + if (!probe.ok) return jsonError(probe.message, 400, { status: probe.status }); + if (probe.models.length > 0 && !probe.models.includes(body.model)) { + return jsonError(`Your key can't use ${body.model}. Pick one from the list.`, 400); + } + return jsonOk({ current: view(await setOwnModelChoice(userId, body.model)) }); + } + + const config = { vendor: body.vendor, model: body.model, apiKey: body.apiKey }; + if (!isByokConfig(config)) return jsonError("That doesn't look like a key.", 400); + + // Never trust a client's "it worked": the key is checked again, here, before + // it is stored — a saved key that does not work is a chat that fails later + // with nobody knowing why. + const probe = await probeByokKey(config.vendor, config.apiKey); + if (!probe.ok) return jsonError(probe.message, 400, { status: probe.status }); + if (probe.models.length > 0 && !probe.models.includes(config.model)) { + return jsonError(`Your key can't use ${config.model}. Pick one from the list.`, 400); + } + return jsonOk({ current: view(await saveOwnModel(userId, config)) }); +} + +export async function DELETE() { + const userId = await getApiUserId(); + if (!userId) return jsonError("Unauthorized", 401); + await deleteOwnModel(userId); + return jsonOk({ current: null }); +} diff --git a/src/components/settings/OwnModelSettings.tsx b/src/components/settings/OwnModelSettings.tsx new file mode 100644 index 000000000..fa7030d47 --- /dev/null +++ b/src/components/settings/OwnModelSettings.tsx @@ -0,0 +1,381 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Check, ExternalLink, KeyRound, Loader2, X } from "lucide-react"; +import { BYOK_VENDORS, byokVendor, type ByokVendorId } from "@bitbaum/ai-kit/byok"; + +/** + * "Power Loki with your own model" — the whole flow in one place. + * + * Built so that connecting a model takes one paste: pick a provider, open its + * key page, paste, and the key is checked on the spot. If it works, the models + * it can actually use appear with the strongest one already chosen (ai-kit's + * probe reads the vendor's own list — it never guesses names). One click saves. + * + * Every state says what it means for the reader, including the two failure + * shapes that must not be confused: the vendor REFUSED the key (their words, + * shown), versus we COULD NOT CHECK it (a vendor hiccup — try again). + */ + +type Current = { vendor: ByokVendorId; model: string; keyHint: string; verifiedAt: string } | null; + +type Probe = + | { state: "idle" } + | { state: "checking" } + | { state: "works"; message: string; models: string[]; suggested: string | null } + | { state: "refused"; message: string }; + +async function post(url: string, method: string, body?: unknown): Promise { + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const data = (await res.json().catch(() => ({}))) as T & { error?: string }; + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); + return data; +} + +export function OwnModelSettings() { + const [loaded, setLoaded] = useState(false); + const [available, setAvailable] = useState(true); + const [current, setCurrent] = useState(null); + const [editing, setEditing] = useState(false); + + const [vendor, setVendor] = useState(BYOK_VENDORS[0]!.id); + const [apiKey, setApiKey] = useState(""); + const [probe, setProbe] = useState({ state: "idle" }); + const [model, setModel] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const probeSeq = useRef(0); + + useEffect(() => { + let alive = true; + void (async () => { + try { + const data = await post<{ available: boolean; current: Current }>( + "/api/settings/model", + "GET", + ); + if (!alive) return; + setAvailable(data.available); + setCurrent(data.current); + } catch { + if (alive) setError("Couldn't read your model settings just now."); + } finally { + if (alive) setLoaded(true); + } + })(); + return () => { + alive = false; + }; + }, []); + + /** Check a key (or, with none, the stored one) and load what it can use. */ + const runProbe = useCallback(async (forVendor: ByokVendorId, key: string | null) => { + const seq = ++probeSeq.current; + setProbe({ state: "checking" }); + setError(null); + try { + const data = await post<{ + works: boolean; + message: string; + models: string[]; + suggested: string | null; + }>("/api/settings/model/probe", "POST", { + vendor: forVendor, + ...(key ? { apiKey: key } : {}), + }); + if (seq !== probeSeq.current) return; // a newer paste has taken over + if (data.works) { + setProbe({ + state: "works", + message: data.message, + models: data.models, + suggested: data.suggested, + }); + setModel(data.suggested ?? ""); + } else { + setProbe({ state: "refused", message: data.message }); + } + } catch (e) { + if (seq === probeSeq.current) { + setProbe({ + state: "refused", + message: e instanceof Error ? e.message : "Couldn't check the key.", + }); + } + } + }, []); + + // Check a pasted key as soon as the reader stops typing — no "Check" button + // to find. Short keys are not sent: nothing a vendor issues is that short. + useEffect(() => { + const key = apiKey.trim(); + if (key.length < 8) return; + const timer = setTimeout(() => void runProbe(vendor, key), 600); + return () => clearTimeout(timer); + }, [apiKey, vendor, runProbe]); + + function startConnect(replaceKey: boolean) { + setEditing(true); + setError(null); + setApiKey(""); + setModel(""); + setProbe({ state: "idle" }); + if (current && !replaceKey) { + // Change model only: list what the stored key can use, no paste needed. + setVendor(current.vendor); + void runProbe(current.vendor, null); + } + } + + async function save() { + setSaving(true); + setError(null); + try { + const data = await post<{ current: Current }>("/api/settings/model", "PUT", { + vendor, + model: model.trim(), + ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), + }); + setCurrent(data.current); + setEditing(false); + setApiKey(""); + setProbe({ state: "idle" }); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't save."); + } finally { + setSaving(false); + } + } + + async function disconnect() { + setSaving(true); + setError(null); + try { + await post("/api/settings/model", "DELETE"); + setCurrent(null); + setEditing(false); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't disconnect."); + } finally { + setSaving(false); + } + } + + const info = byokVendor(vendor)!; + const changingModelOnly = + editing && current !== null && current.vendor === vendor && !apiKey.trim(); + const canSave = + !saving && + probe.state === "works" && + model.trim().length > 0 && + (apiKey.trim().length > 0 || changingModelOnly); + + return ( +
+
+

+

+

+ Loki runs on free, shared models with a daily budget. Connect a key from any provider + below and Loki thinks with the best model your account can use instead. Loki's daily + budget no longer applies to your chats; your provider bills you for what you use. +

+
+ + {!loaded && ( +

+

+ )} + + {loaded && !available && ( +

+ Connecting your own model isn't switched on for this server yet — Loki keeps using + its free models. +

+ )} + + {loaded && available && current && !editing && ( +
+
+ )} + + {loaded && available && (!current || editing) && ( +
+
+

1 · Your provider

+
+ {BYOK_VENDORS.map((v) => ( + + ))} +
+ {vendor === "openrouter" && ( +

+ One OpenRouter key reaches models from every major lab — the easiest way to use the + strongest ones. +

+ )} +
+ +
+

2 · Your key

+ + Get a key from {info.label}{" "} + + setApiKey(e.target.value)} + autoComplete="off" + spellCheck={false} + aria-label={`${info.label} API key`} + /> +
+ {probe.state === "checking" && ( + + + )} + {probe.state === "works" && ( + + + )} + {probe.state === "refused" && ( + + + )} +
+
+ + {probe.state === "works" && ( +
+

3 · Your model

+ {probe.models.length > 0 ? ( + + ) : ( + setModel(e.target.value)} + aria-label="Model id" + /> + )} + {probe.suggested && model === probe.suggested && ( +

+ Chosen for you: the newest model in the strongest tier your key can use. Pick + another any time. +

+ )} +
+ )} + +
+ + {editing && ( + + )} +
+ +

+ Your key is encrypted before it's stored and is only used for your own Loki chats. + It is never shown again — only its last four characters. Remove it here any time. +

+
+ )} + + {error &&

{error}

} +
+ ); +} diff --git a/src/components/settings/SettingsTabs.tsx b/src/components/settings/SettingsTabs.tsx index f0a9dcd89..6b6775d9b 100644 --- a/src/components/settings/SettingsTabs.tsx +++ b/src/components/settings/SettingsTabs.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, Suspense } from "react"; +import { useCallback, useSyncExternalStore, Suspense } from "react"; import { ScrollAffordance } from "@/components/ui/scroll-affordance"; import { ProfileSettings } from "./ProfileSettings"; import { AccountSettings } from "./AccountSettings"; @@ -9,6 +9,7 @@ import { PrivacySettings } from "./PrivacySettings"; import { LocationSettings } from "./LocationSettings"; import { VoiceSettings } from "./VoiceSettings"; import { AiQuotaSettings } from "./AiQuotaSettings"; +import { OwnModelSettings } from "./OwnModelSettings"; import { ProviderOrderSettings } from "./ProviderOrderSettings"; import { AgentTokenSettings } from "./AgentTokenSettings"; import { BeaconSettings } from "./BeaconSettings"; @@ -63,11 +64,15 @@ const HASH_TO_TAB: Record = { tokens: "agent", // /control's RunnerStatusBanner deep-links to #tokens "agent-token": "agent", "agent-tokens": "agent", + // "Use your own model" links — from the budget refusal, docs, other apps. + model: "ai", + "own-model": "ai", + byok: "ai", + keys: "ai", }; -// Resolve the initial tab from window.location.hash. Pure function so it can -// run inside a useState lazy initializer — avoids the cascading-render lint -// hit from a post-mount setState in useEffect. +// The tab the URL hash names. Pure, so it can be the tab store's client +// snapshot (see useSyncExternalStore in SettingsTabs). function resolveInitialTab(): TabId { if (typeof window === "undefined") return "profile"; const raw = window.location.hash.slice(1).toLowerCase(); @@ -77,6 +82,23 @@ function resolveInitialTab(): TabId { return HASH_TO_TAB[raw] ?? "profile"; } +/** Fired when a tab click rewrites the hash with replaceState (no hashchange). */ +const TAB_EVENT = "settings-tab"; + +function subscribeToTab(onChange: () => void): () => void { + window.addEventListener("hashchange", onChange); + window.addEventListener(TAB_EVENT, onChange); + return () => { + window.removeEventListener("hashchange", onChange); + window.removeEventListener(TAB_EVENT, onChange); + }; +} + +/** What the server renders — it cannot see a hash. */ +function serverTab(): TabId { + return "profile"; +} + export function SettingsTabs({ user, userPrefs, @@ -86,39 +108,31 @@ export function SettingsTabs({ invitations, orangecatEnabled, }: Props) { - // Lazy initializer reads the URL hash once at first render so deep links - // like /settings#agent or /settings#tokens (from RunnerStatusBanner's - // onboarding link) open the right tab. Without this, the banner landed - // the user on Profile and they had to discover the Agent tab themselves - // — a silent dead-end in the new-user funnel. - const [activeTab, setActiveTab] = useState(resolveInitialTab); - /** - * The hash is read ONCE by the lazy initializer above, which covers arriving - * from elsewhere but not changing the hash while already here. So - * /settings#agent worked from /control and did nothing from /settings — - * including browser Back after switching tabs, which moved the URL and left - * the page on whatever was open. Every in-app link to a settings section is - * therefore a coin flip depending on where the operator happened to be. + * The open tab IS the URL hash — one source of truth, read through + * useSyncExternalStore so server and client agree. + * + * It used to be `useState(resolveInitialTab)`: the server, which has no + * hash, rendered Profile; the browser, reading #ai, rendered AI; React threw + * a hydration mismatch on every deep link into Settings and rebuilt the + * tree. The store below gives React the server's answer ("profile") while + * hydrating and the hash's answer right after, so deep links such as + * /settings#agent (RunnerStatusBanner) and /settings#ai (the free-budget + * refusal's "connect your own model") open the right tab with no mismatch — + * and hash changes while already here (Back, in-app links) still follow. */ - useEffect(() => { - const onHashChange = () => setActiveTab(resolveInitialTab()); - window.addEventListener("hashchange", onHashChange); - return () => window.removeEventListener("hashchange", onHashChange); - }, []); + const activeTab = useSyncExternalStore(subscribeToTab, resolveInitialTab, serverTab); /** - * And the reverse: clicking a tab only moved React state, so the URL still - * said whatever it said. The address bar could not be shared, bookmarked or - * reloaded onto the section actually being looked at. replaceState rather - * than a hash assignment so switching tabs does not stack history entries — + * Clicking a tab writes the hash, so the address bar can be shared, + * bookmarked or reloaded onto the section being looked at. replaceState + * rather than a hash assignment so switching tabs does not stack history — * Back should leave Settings, not walk you through every tab you opened. + * replaceState fires no event, so the store is told directly. */ const selectTab = useCallback((id: TabId) => { - setActiveTab(id); - if (typeof window !== "undefined") { - window.history.replaceState(null, "", `#${id}`); - } + window.history.replaceState(null, "", `#${id}`); + window.dispatchEvent(new Event(TAB_EVENT)); }, []); return ( @@ -175,7 +189,13 @@ export function SettingsTabs({ {activeTab === "notifications" && } {activeTab === "appearance" && } {activeTab === "voice" && } - {activeTab === "ai" && } + {activeTab === "ai" && ( +
+ {/* Your own model first: it is the way past the shared pool below. */} + + +
+ )} {activeTab === "privacy" && } {activeTab === "location" && } {activeTab === "agent" && ( diff --git a/src/config/demo.ts b/src/config/demo.ts index 903de6a03..e8a449d52 100644 --- a/src/config/demo.ts +++ b/src/config/demo.ts @@ -107,6 +107,10 @@ export const DEMO_DENIED_PREFIXES: ReadonlyArray= 16 ? secret : null; +} + +export type OwnModelSummary = { + vendor: ByokVendorId; + model: string; + /** "…abcd" — never more of the key than that. */ + keyHint: string; + verifiedAt: Date; +}; + +function summaryOf(row: typeof userModelKeys.$inferSelect): OwnModelSummary | null { + if (!byokVendor(row.vendor)) return null; // a vendor since removed from the closed list + return { + vendor: row.vendor as ByokVendorId, + model: row.model, + keyHint: row.keyHint, + verifiedAt: row.verifiedAt, + }; +} + +/** What the user brought, without the key. Null when they brought nothing. */ +export async function getOwnModelSummary(userId: string): Promise { + const [row] = await db + .select() + .from(userModelKeys) + .where(eq(userModelKeys.userId, userId)) + .limit(1); + return row ? summaryOf(row) : null; +} + +/** + * The user's model WITH its key, for the one request it is needed for. + * + * Null when nothing is stored, the server has no sealing secret, or the value + * will not open (a rotated secret). The last is logged and treated as "no key" + * — a chat turn must not fail because of it; the settings screen asks the user + * to paste the key again. + */ +export async function getOwnModel(userId: string): Promise { + const secret = ownModelSealSecret(); + if (!secret) return null; + const [row] = await db + .select() + .from(userModelKeys) + .where(eq(userModelKeys.userId, userId)) + .limit(1); + if (!row || !byokVendor(row.vendor)) return null; + try { + return { + vendor: row.vendor as ByokVendorId, + model: row.model, + apiKey: openSecret(row.sealedKey, secret, SEAL_CONTEXT), + }; + } catch { + console.error(`[own-model] stored key for user ${userId} did not open — secret rotated?`); + return null; + } +} + +/** Store (or replace) the user's model. Caller has already probed the key. */ +export async function saveOwnModel(userId: string, config: ByokConfig): Promise { + const secret = ownModelSealSecret(); + if (!secret) throw new Error("BYOK_SEAL_SECRET is not set on this server"); + const now = new Date(); + const values = { + vendor: config.vendor, + model: config.model, + sealedKey: sealSecret(config.apiKey, secret, SEAL_CONTEXT), + keyHint: byokKeyHint(config.apiKey), + verifiedAt: now, + updatedAt: now, + }; + const [row] = await db + .insert(userModelKeys) + .values({ userId, ...values }) + .onConflictDoUpdate({ target: userModelKeys.userId, set: values }) + .returning(); + const summary = summaryOf(row!); + if (!summary) throw new Error(`unknown vendor ${config.vendor}`); + return summary; +} + +/** Change only the model, keeping the stored key. Null when nothing is stored. */ +export async function setOwnModelChoice( + userId: string, + model: string, +): Promise { + const [row] = await db + .update(userModelKeys) + .set({ model, updatedAt: new Date() }) + .where(eq(userModelKeys.userId, userId)) + .returning(); + return row ? summaryOf(row) : null; +} + +/** Forget the user's key entirely. */ +export async function deleteOwnModel(userId: string): Promise { + await db.delete(userModelKeys).where(eq(userModelKeys.userId, userId)); +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index f0e5eb4bd..bbbed09a6 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -35,6 +35,7 @@ export * from "./habit-goals"; export * from "./beacon-settings"; export * from "./prompts"; export * from "./user-preferences"; +export * from "./user-model-keys"; export * from "./debug-logs"; export * from "./cron-jobs"; export * from "./runtime-snapshots"; diff --git a/src/db/schema/user-model-keys.ts b/src/db/schema/user-model-keys.ts new file mode 100644 index 000000000..4cc7a8ef5 --- /dev/null +++ b/src/db/schema/user-model-keys.ts @@ -0,0 +1,37 @@ +import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { users } from "./users"; + +/** + * The model a user brought: their own API key at a vendor, and the model they + * chose there. With it, Loki's chat runs on that model — their vendor account, + * their bill — instead of the shared, rationed free chain. + * + * One row per user. The key is stored SEALED (`@bitbaum/ai-kit/seal`, + * AES-256-GCM with the app's `BYOK_SEAL_SECRET`), so the database alone cannot + * read it; `key_hint` ("…abcd") is the only part ever shown back. The vendor is + * an id from ai-kit's closed list, never a URL: the server sends this key to + * the host that id names, and nowhere else. + * + * This is for API keys. A Claude or ChatGPT *subscription* is not stored here + * and never will be — signing in to those happens in the agent's own login + * flow, and Loki does not hold those credentials. + */ +export const userModelKeys = pgTable("user_model_keys", { + userId: uuid("user_id") + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + /** An id from ai-kit's BYOK_VENDORS ("anthropic", "openrouter", …). */ + vendor: text("vendor").notNull(), + /** The model id at that vendor the user picked. */ + model: text("model").notNull(), + /** `iv:tag:ciphertext` from sealSecret(..., "byok"). Never returned by an API. */ + sealedKey: text("sealed_key").notNull(), + /** "…abcd" — the last four characters, for recognising which key this is. */ + keyHint: text("key_hint").notNull(), + /** When the vendor last accepted this key (probed on save). */ + verifiedAt: timestamp("verified_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export type UserModelKey = typeof userModelKeys.$inferSelect; diff --git a/src/lib/agent/llm.ts b/src/lib/agent/llm.ts index ebc03e95b..68e6c0901 100644 --- a/src/lib/agent/llm.ts +++ b/src/lib/agent/llm.ts @@ -31,6 +31,7 @@ import { HTTP_TIMEOUT_LONG_MS } from "@/lib/constants/time"; import { classifyGroqLimit, groqRetryAfterSeconds, humanizeWait } from "@/lib/agent/groq-error"; import { chainFrom, linkPromptCeilingTokens, type ChatLink } from "@/config/chat-models"; +import { keyForLink, type OwnModel } from "@/lib/own-model"; import { recordAIHealthFailure, recordAIHealthSuccess } from "@/lib/ai/health"; import { recordVendorQuota, recordPreflightSkip, recordRefusal } from "@/lib/ai/record-quota"; import { readSseChunks } from "@/lib/agent/sse-stream"; @@ -275,6 +276,13 @@ export type ModelCallInput = { * simply have walked past. */ promptTokens?: number; + /** + * The user's own model (see src/lib/own-model.ts). Present = walk ONLY that + * chain, with its key from `own.env`, and keep it out of every record Loki + * keeps about its OWN vendors: the quota page, refusals, usage, AI health. + * A user's Anthropic account running dry is not Loki's Groq pool draining. + */ + own?: OwnModel; /** * Present = stream this call and hand prose to the operator as it arrives. * @@ -326,12 +334,21 @@ async function callOneLink( input: ModelCallInput, tools: Array>, ): Promise { - const key = process.env[link.provider.keyEnv]; - if (!key) throw new LinkError("other", `${link.provider.keyEnv} not set`); + const key = keyForLink(link, input.own); + if (!key) { + throw new LinkError( + "other", + input.own ? "your key is not available" : `${link.provider.keyEnv} not set`, + ); + } const res = await fetch(`${link.provider.baseUrl}/chat/completions`, { method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${key}`, + ...(input.own?.extraHeaders ?? {}), + }, body: JSON.stringify({ model: link.model, messages: input.messages, @@ -355,7 +372,7 @@ async function callOneLink( // reading — it corrects a local counter that had drifted optimistic. Never // awaited and never able to throw: a telemetry write must not be able to fail // an answer the operator is waiting for. - recordVendorQuota(res.headers, link); + if (!input.own) recordVendorQuota(res.headers, link); if (!res.ok) { const body = await res.text().catch(() => ""); @@ -376,7 +393,7 @@ async function callOneLink( // provider through an outage. A "size" 429 is deliberately excluded — it // reports THIS prompt being too big, which is a fact about the prompt and // not about how much allowance is left. - if (kind !== "size") { + if (kind !== "size" && !input.own) { recordRefusal(link, body, retryAfter, kind === "daily" ? "tokens" : "requests"); } // Keep the wording the shed ladder greps for — `loop.ts` recognises @@ -751,7 +768,7 @@ export async function callModelLinkOnce(link: ChatLink, input: ModelCallInput): export async function callModelWithTools( input: ModelCallInput & { /** Internal: set after the one bounded wait. */ waited429?: boolean }, ): Promise { - const chain = chainFrom(input.model ?? process.env.LOKI_MODEL); + const chain = input.own ? input.own.chain : chainFrom(input.model ?? process.env.LOKI_MODEL); if (chain.length === 0) { const error = new Error("no chat provider configured (set GROQ_API_KEY or OPENROUTER_API_KEY)"); recordAIHealthFailure(error); @@ -765,7 +782,9 @@ export async function callModelWithTools( const skipped: string[] = []; for (const link of chain) { if (drained.has(link.provider.id)) continue; - if (input.promptTokens !== undefined) { + // The budgets below are Loki's FREE-tier windows. A user's own key is sized + // by their vendor, which says so itself if a prompt is too big. + if (input.promptTokens !== undefined && !input.own) { // The CEILING, not the sizing budget: skipping a link that would have // answered costs a 25-second detour and one of 50 daily free requests, // while trying one that refuses costs a 1-second 429. See @@ -817,11 +836,14 @@ export async function callModelWithTools( } // Recorded once per top-level call — a later link answering is the // fallback doing its job, not a health problem. - recordAIHealthSuccess(); // What it cost, charged to the caller that asked. Fire-and-forget and // caught, like every other write on this path: telemetry must not be - // able to fail an answer someone is waiting for. - recordUsage(link.provider.id, link.model, input.feature, turn.usageTokens); + // able to fail an answer someone is waiting for. Neither applies to a + // user's own key — it is their vendor's meter, not Loki's. + if (!input.own) { + recordAIHealthSuccess(); + recordUsage(link.provider.id, link.model, input.feature, turn.usageTokens); + } return turn; } catch (e) { if (emitted) outer?.reset(); @@ -866,6 +888,6 @@ export async function callModelWithTools( // `size` is a request-shape problem, not a vendor outage — recording it as // AI-down would make /api/health flap on prompts that are simply too big, // which is a caller bug (see loop.ts's shedding), not a chain failure. - if (!kinds.has("size")) recordAIHealthFailure(exhausted); + if (!kinds.has("size") && !input.own) recordAIHealthFailure(exhausted); throw exhausted; } diff --git a/src/lib/agent/loop.ts b/src/lib/agent/loop.ts index eb39a2740..74319333e 100644 --- a/src/lib/agent/loop.ts +++ b/src/lib/agent/loop.ts @@ -52,6 +52,7 @@ import { type ToolRegistry, } from "@/lib/agent/tools/registry"; import { maxPromptBudgetTokens } from "@/config/chat-models"; +import type { OwnModel } from "@/lib/own-model"; import type { RetrievedSource } from "@/lib/agent/context"; import { APP_NAME } from "@/config/brand"; import { looksLikePlan, ANSWER_ONLY } from "@/lib/loki/plan-as-answer"; @@ -336,6 +337,11 @@ export async function runLokiTurn(input: { seed?: LoopSeed; /** Injected in tests; defaults to the largest usable link's budget. */ promptBudgetTokens?: number; + /** + * The user's own model (src/lib/own-model.ts). Every model call in the turn + * runs on it, and prompts are sized for it instead of for the free chain. + */ + own?: OwnModel; }): Promise { const registry = input.registry ?? (await defaultRegistry()); const callModel = input.callModel ?? callModelWithTools; @@ -344,7 +350,8 @@ export async function runLokiTurn(input: { const names = toolNames(registry); const ctx = { userId: input.userId, message: input.message }; const budgetTokens = - input.promptBudgetTokens ?? (maxPromptBudgetTokens() || FALLBACK_PROMPT_BUDGET_TOKENS); + input.promptBudgetTokens ?? + (maxPromptBudgetTokens(input.own?.chain) || FALLBACK_PROMPT_BUDGET_TOKENS); const seed = input.seed ?? (await defaultSeed(input.userId, input.message)); const directives = seed.directives; @@ -461,6 +468,7 @@ export async function runLokiTurn(input: { lastSystem = system; try { return await callModel({ + own: input.own, // One label for the whole turn: a round, a plan retry and a repair // are the same question being answered, and three lines on the // capacity page would read as three features. @@ -540,6 +548,7 @@ export async function runLokiTurn(input: { sink?.reset(); try { const retry = await callModel({ + own: input.own, feature: "loki-chat", messages: [ { role: "system", content: lastSystem }, @@ -596,6 +605,7 @@ export async function runLokiTurn(input: { // Repair asks for DELETION, not regeneration — the model is not missing // knowledge, it added claims. Tools stay off so it cannot wander further. const repaired = await callModel({ + own: input.own, feature: "loki-chat", messages: [ { role: "system", content: systemPrompt(registry, false) }, diff --git a/src/lib/ai-budget/gate.ts b/src/lib/ai-budget/gate.ts index 05761b600..578b51fc3 100644 --- a/src/lib/ai-budget/gate.ts +++ b/src/lib/ai-budget/gate.ts @@ -2,6 +2,7 @@ import { fairShare, utcDayElapsed, type ShareDecision } from "@/lib/ai-budget/fa import { dayCapacityTokens } from "@/config/chat-models"; import { humanizeWait } from "@/lib/agent/groq-error"; import type { DayUsage } from "@/db/queries/ai-spend"; +import { OWN_MODEL_SETTINGS_PATH } from "@/lib/own-model-path"; /** * The ledger is imported LAZILY, for the same reason `loop.ts` does it: `@/db` @@ -50,6 +51,13 @@ export type BudgetVerdict = { allowed: true } | { allowed: false; message: string; retryAfterSeconds?: number }; /** Turn a refusal into something the operator can act on. */ +/** + * The way past the shared pool, offered at the moment it is needed. A refusal + * that only says "wait" leaves out the one thing that ends the wait now: the + * person's own key, which Loki uses for their chats with no daily limit. + */ +const OWN_MODEL_OFFER = `Or [connect your own model](${OWN_MODEL_SETTINGS_PATH}) with a key from any provider you use, and Loki's daily budget no longer applies to your chats.`; + function explain(decision: ShareDecision): string { switch (decision.reason) { case "paced": { @@ -57,14 +65,14 @@ function explain(decision: ShareDecision): string { // Says WHY there is a wait, not just that there is one — "you are being // rationed so someone else can also use this today" is a reason a person // accepts; an unexplained refusal reads as breakage. - return `You've used your share of today's free AI budget for now. It tops up through the day so everyone gets a turn${wait ? ` — try again in ${wait}` : ""}.`; + return `You've used your share of today's free AI budget for now. It tops up through the day so everyone gets a turn${wait ? ` — try again in ${wait}` : ""}. ${OWN_MODEL_OFFER}`; } case "share-spent": // No retry offered: waiting cannot help today, and saying otherwise is the // same lie as "try again shortly" on an exhausted daily quota. - return "You've used your full share of today's free AI budget. It resets at midnight UTC."; + return `You've used your full share of today's free AI budget. It resets at midnight UTC. ${OWN_MODEL_OFFER}`; case "no-capacity": - return "No AI provider is configured, so there is no budget to draw on."; + return `No AI provider is configured on this server. [Connect your own model](${OWN_MODEL_SETTINGS_PATH}) to use Loki now.`; case "ok": return ""; } diff --git a/src/lib/loki-core.ts b/src/lib/loki-core.ts index 9c40c7d88..ce5efafde 100644 --- a/src/lib/loki-core.ts +++ b/src/lib/loki-core.ts @@ -40,6 +40,13 @@ import { import { NO_BASIS } from "@bitbaum/ai-kit/grounding"; import { rateLimitMessage } from "@/lib/agent/groq-error"; import { checkAiBudget, recordAiSpend } from "@/lib/ai-budget/gate"; +import { getOwnModel } from "@/db/queries/user-model-keys"; +import { + ownModelFrom, + OWN_MODEL_KEY_ENV, + OWN_MODEL_SETTINGS_PATH, + type OwnModel, +} from "@/lib/own-model"; import type { Fact } from "@bitbaum/ai-kit/grounding"; import { APP_NAME } from "@/config/brand"; import { ECOSYSTEM, ORANGECAT_CAPABILITIES } from "@/config/ecosystem"; @@ -210,6 +217,17 @@ export type AskLokiOpts = { */ export async function askLoki(message: string, opts?: AskLokiOpts): Promise { const startedAt = Date.now(); + + // A user who brought their own model runs on it: their vendor, their bill. + // So none of the free-pool machinery applies — not the ration, not the + // spend ledger, and not the fallback to the shared chain when their key + // fails, which would quietly spend the pool they opted out of and hide the + // one thing they need to hear: that their key stopped working. + const own = opts?.userId ? await loadOwnModel(opts.userId) : null; + if (own && opts?.userId) { + return askLokiOnOwnModel(message, { ...opts, userId: opts.userId }, own, startedAt); + } + // Ration BEFORE any provider is called, and only for identified users — // an anonymous caller has no ledger to charge, and the paths they can reach // do not draw on the rationed pool. The whole turn is gated once, not each @@ -282,6 +300,79 @@ export async function askLoki(message: string, opts?: AskLokiOpts): Promise { + try { + const config = await getOwnModel(userId); + return config ? ownModelFrom(config) : null; + } catch (e) { + // A read failure means "use the free chain as before", never "no answer". + console.error("[loki] own model unavailable:", e instanceof Error ? e.message : e); + return null; + } +} + +/** + * A turn on the user's own model: the tool loop only, on their chain. + * + * Their failure is theirs to see. A revoked key, an empty balance or a model + * their account cannot use comes back as a sentence naming their vendor and + * what it said — with where to fix it — instead of an answer from somewhere + * else that would leave them thinking their key works. + */ +async function askLokiOnOwnModel( + message: string, + opts: AskLokiOpts & { userId: string }, + own: OwnModel, + startedAt: number, +): Promise { + try { + const voicePref = await getUserPreferences(opts.userId) + .then((p) => p.writingVoice) + .catch(() => null); + const result = await runLokiTurn({ + userId: opts.userId, + message, + voice: voicePref, + history: opts.history, + onEvent: opts.onEvent, + own, + }); + if (!result.text.trim()) { + throw new Error("it returned an empty answer"); + } + const provenance: LokiProvenance = { + via: "tool-loop", + model: `your key: ${own.label}`, + durationMs: Date.now() - startedAt, + toolsUsed: result.toolsUsed, + rounds: result.rounds, + retrieved: result.retrieved, + grounding: groundingMeta(result.facts.length, result.violations), + }; + logTurn({ ...provenance, userId: opts.userId, textLength: result.text.length }); + return { + status: 200, + body: { ok: true, text: result.text, ...provenance, sources: result.sources }, + }; + } catch (e) { + // Vendors usually mask a key they echo; the ones that don't must not have + // it shown, logged or sent back. + const key = own.env[OWN_MODEL_KEY_ENV] ?? ""; + const raw = e instanceof Error ? e.message : String(e); + const detail = key ? raw.split(key).join(`…${key.slice(-4)}`) : raw; + console.error(`[loki] own model (${own.label}) failed:`, detail); + return { + status: 502, + body: { + error: + `Your model (${own.label}) didn't answer: ${detail.slice(0, 240)}. ` + + "Check the key or pick another model in Settings → AI.", + settingsUrl: OWN_MODEL_SETTINGS_PATH, + }, + }; + } +} + /** The fallback path: the same grounded seed + gateway/Groq. */ async function askLokiViaGateway( message: string, diff --git a/src/lib/own-model-path.ts b/src/lib/own-model-path.ts new file mode 100644 index 000000000..73143b542 --- /dev/null +++ b/src/lib/own-model-path.ts @@ -0,0 +1,8 @@ +/** + * Where a user connects, changes or removes their own model — Settings → AI. + * + * Its own module, with no imports, because the places that point people here + * include the budget gate, which is loaded in the database-free test tier and + * must not pull in the model plumbing to know a path. + */ +export const OWN_MODEL_SETTINGS_PATH = "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/settings#ai"; diff --git a/src/lib/own-model.ts b/src/lib/own-model.ts new file mode 100644 index 000000000..b6f31234c --- /dev/null +++ b/src/lib/own-model.ts @@ -0,0 +1,58 @@ +import { byokChain, byokLabel, type ByokConfig } from "@bitbaum/ai-kit/byok"; +import type { ChatLink } from "@/config/chat-models"; +import { appUrl } from "@/lib/email"; + +/** + * A user's own model, shaped for Loki's model walker. + * + * `byokChain` returns a one-link chain whose key lives in a per-call `env` + * object — never `process.env` — under this name. The walker reads a link's + * key from that object first, and a server link's key names (GROQ_API_KEY, …) + * never appear in it, so a user's key cannot reach a server link and a server + * key cannot reach a user's link. `scripts/test/own-model.ts` pins that the + * name here is the one ai-kit actually uses. + */ +export const OWN_MODEL_KEY_ENV = "BYOK_API_KEY"; + +export { OWN_MODEL_SETTINGS_PATH } from "@/lib/own-model-path"; + +export type OwnModel = { + chain: ChatLink[]; + env: Record; + /** OpenRouter's attribution headers, when the vendor reads them. */ + extraHeaders?: Record; + /** "Anthropic · claude-opus-5.5" — for provenance and the settings screen. Never the key. */ + label: string; + vendor: string; +}; + +export function ownModelFrom(config: ByokConfig): OwnModel { + const { chain, env, extraHeaders } = byokChain(config, { url: appUrl(), title: "Loki" }); + return { + chain, + env: env as Record, + ...(extraHeaders ? { extraHeaders } : {}), + label: byokLabel(config), + vendor: config.vendor, + }; +} + +/** True for a link that carries a user's own key rather than one of the server's. */ +export function isOwnModelLink(link: ChatLink): boolean { + return link.provider.keyEnv === OWN_MODEL_KEY_ENV; +} + +/** + * The key for one link, from the right place. + * + * A user's link reads ONLY the per-call env — if it were allowed to fall back + * to `process.env`, a deployment that happened to set BYOK_API_KEY would + * answer every user's turn on the operator's account. A server link reads ONLY + * `process.env`, so nothing a user supplies can stand in for a server key. + */ +export function keyForLink( + link: ChatLink, + own: Pick | undefined, +): string | undefined { + return isOwnModelLink(link) ? own?.env[link.provider.keyEnv] : process.env[link.provider.keyEnv]; +}