From ea1d0fb85550c57c38da354dc7b074f9e1d8c498 Mon Sep 17 00:00:00 2001
From: Cato
Date: Fri, 25 Sep 2026 17:05:05 +0200
Subject: [PATCH 1/7] wip: user_model_keys table + ai-kit 1.17.0
---
drizzle/0081_user_model_keys.sql | 26 ++++++++++++++++++++++
drizzle/meta/_journal.json | 7 ++++++
package.json | 2 +-
pnpm-lock.yaml | 10 ++++-----
src/db/schema/index.ts | 1 +
src/db/schema/user-model-keys.ts | 37 ++++++++++++++++++++++++++++++++
6 files changed, 77 insertions(+), 6 deletions(-)
create mode 100644 drizzle/0081_user_model_keys.sql
create mode 100644 src/db/schema/user-model-keys.ts
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/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;
From 2245482bc958562869696eea9281182076e2fed9 Mon Sep 17 00:00:00 2001
From: Cato
Date: Fri, 25 Sep 2026 17:09:06 +0200
Subject: [PATCH 2/7] wip: chat runs on the user's own model
---
src/db/queries/user-model-keys.ts | 126 ++++++++++++++++++++++++++++++
src/lib/agent/llm.ts | 44 ++++++++---
src/lib/agent/loop.ts | 12 ++-
src/lib/loki-core.ts | 89 +++++++++++++++++++++
src/lib/own-model.ts | 59 ++++++++++++++
5 files changed, 318 insertions(+), 12 deletions(-)
create mode 100644 src/db/queries/user-model-keys.ts
create mode 100644 src/lib/own-model.ts
diff --git a/src/db/queries/user-model-keys.ts b/src/db/queries/user-model-keys.ts
new file mode 100644
index 000000000..f90ec4a06
--- /dev/null
+++ b/src/db/queries/user-model-keys.ts
@@ -0,0 +1,126 @@
+import { eq } from "drizzle-orm";
+import { sealSecret, openSecret } from "@bitbaum/ai-kit/seal";
+import { byokKeyHint, byokVendor, type ByokConfig, type ByokVendorId } from "@bitbaum/ai-kit/byok";
+import { db } from "@/db";
+import { userModelKeys } from "@/db/schema/user-model-keys";
+
+/**
+ * The model a user brought — see src/db/schema/user-model-keys.ts.
+ *
+ * The key leaves this module in exactly one place: `getOwnModel`, which a chat
+ * turn calls to make the request. Everything the settings screen reads goes
+ * through `getOwnModelSummary`, which has no key in it.
+ */
+
+/** "byok": what the value was sealed for; a value sealed for anything else does not open here. */
+const SEAL_CONTEXT = "byok";
+
+/**
+ * The app's sealing secret, or null when this deployment has none.
+ *
+ * Null is a normal state, not a crash: the settings screen says "not available
+ * on this server" and chat keeps running on the free chain. Without the secret
+ * a key could only be stored in the clear, which this module refuses to do.
+ */
+export function ownModelSealSecret(): string | null {
+ const secret = process.env.BYOK_SEAL_SECRET?.trim();
+ return secret && secret.length >= 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/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..175704475 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/loki-core.ts b/src/lib/loki-core.ts
index 9c40c7d88..114b6e365 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,15 @@ 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, 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 +298,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.ts b/src/lib/own-model.ts
new file mode 100644
index 000000000..7c1855a03
--- /dev/null
+++ b/src/lib/own-model.ts
@@ -0,0 +1,59 @@
+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";
+
+/** Where a user connects, changes or removes their model — the AI tab of Settings. */
+export const OWN_MODEL_SETTINGS_PATH = "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/settings#ai";
+
+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];
+}
From 6420140d2036ce2b24bc5a7abf7148ded0dbd0fc Mon Sep 17 00:00:00 2001
From: Cato
Date: Fri, 25 Sep 2026 17:12:24 +0200
Subject: [PATCH 3/7] wip: settings API + screen for the user's own model
---
src/app/api/settings/model/probe/route.ts | 55 +++
src/app/api/settings/model/route.ts | 103 ++++++
src/components/settings/OwnModelSettings.tsx | 343 +++++++++++++++++++
src/components/settings/SettingsTabs.tsx | 14 +-
4 files changed, 514 insertions(+), 1 deletion(-)
create mode 100644 src/app/api/settings/model/probe/route.ts
create mode 100644 src/app/api/settings/model/route.ts
create mode 100644 src/components/settings/OwnModelSettings.tsx
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..a8ec9c2e0
--- /dev/null
+++ b/src/components/settings/OwnModelSettings.tsx
@@ -0,0 +1,343 @@
+"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 (
+
+
+
+
+ Power Loki with your own model
+
+
+ Loki runs on free, shared models with a daily limit. Connect a key from any provider below
+ and Loki thinks with the best model your account can use — no daily limit, billed to you by
+ your provider.
+
+
+
+ {!loaded && (
+
+ Reading your settings
+
+ )}
+
+ {loaded && !available && (
+
+ Connecting your own model isn't switched on for this server yet — Loki keeps using its
+ free models.
+
+ )}
+
+ {loaded && available && current && !editing && (
+
+
+
+
+ Loki is thinking with {byokVendor(current.vendor)?.label ?? current.vendor} ·{" "}
+ {current.model}
+
+
+ Your key {current.keyHint} · checked {new Date(current.verifiedAt).toLocaleDateString()}
+
+ 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..09786b37f 100644
--- a/src/components/settings/SettingsTabs.tsx
+++ b/src/components/settings/SettingsTabs.tsx
@@ -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,6 +64,11 @@ 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
@@ -175,7 +181,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" && (
From fc77a5df50692ee413c7faffd5d5359f2de2e223 Mon Sep 17 00:00:00 2001
From: Cato
Date: Fri, 25 Sep 2026 17:19:34 +0200
Subject: [PATCH 4/7] wip: budget refusal offers your own model; tests
---
scripts/test/own-model.ts | 142 +++++++++++++++++++
src/components/settings/OwnModelSettings.tsx | 78 +++++++---
src/lib/agent/loop.ts | 4 +-
src/lib/ai-budget/gate.ts | 14 +-
src/lib/own-model-path.ts | 8 ++
src/lib/own-model.ts | 3 +-
6 files changed, 222 insertions(+), 27 deletions(-)
create mode 100644 scripts/test/own-model.ts
create mode 100644 src/lib/own-model-path.ts
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/components/settings/OwnModelSettings.tsx b/src/components/settings/OwnModelSettings.tsx
index a8ec9c2e0..fa7030d47 100644
--- a/src/components/settings/OwnModelSettings.tsx
+++ b/src/components/settings/OwnModelSettings.tsx
@@ -83,17 +83,28 @@ export function OwnModelSettings() {
message: string;
models: string[];
suggested: string | null;
- }>("/api/settings/model/probe", "POST", { vendor: forVendor, ...(key ? { apiKey: key } : {}) });
+ }>("/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 });
+ 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." });
+ setProbe({
+ state: "refused",
+ message: e instanceof Error ? e.message : "Couldn't check the key.",
+ });
}
}
}, []);
@@ -155,9 +166,13 @@ export function OwnModelSettings() {
}
const info = byokVendor(vendor)!;
- const changingModelOnly = editing && current !== null && current.vendor === vendor && !apiKey.trim();
+ 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);
+ !saving &&
+ probe.state === "works" &&
+ model.trim().length > 0 &&
+ (apiKey.trim().length > 0 || changingModelOnly);
return (
@@ -167,9 +182,9 @@ export function OwnModelSettings() {
Power Loki with your own model
- Loki runs on free, shared models with a daily limit. Connect a key from any provider below
- and Loki thinks with the best model your account can use — no daily limit, billed to you by
- your provider.
+ 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.
- Connecting your own model isn't switched on for this server yet — Loki keeps using its
- free models.
+ Connecting your own model isn't switched on for this server yet — Loki keeps using
+ its free models.
)}
@@ -195,17 +210,31 @@ export function OwnModelSettings() {
{current.model}
- Your key {current.keyHint} · checked {new Date(current.verifiedAt).toLocaleDateString()}
+ Your key {current.keyHint} · checked{" "}
+ {new Date(current.verifiedAt).toLocaleDateString()}
-
@@ -250,13 +279,16 @@ export function OwnModelSettings() {
rel="noopener noreferrer"
className="mb-2 inline-flex items-center gap-1 text-sm text-accent-text underline-offset-2 hover:underline"
>
- Get a key from {info.label}
+ Get a key from {info.label}{" "}
+
setApiKey(e.target.value)}
@@ -267,7 +299,8 @@ export function OwnModelSettings() {
- void save()}>
+ void save()}
+ >
{saving ? : null}
Use this model
@@ -331,8 +369,8 @@ export function OwnModelSettings() {
- 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.
+ 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.
)}
diff --git a/src/lib/agent/loop.ts b/src/lib/agent/loop.ts
index 175704475..74319333e 100644
--- a/src/lib/agent/loop.ts
+++ b/src/lib/agent/loop.ts
@@ -548,7 +548,7 @@ export async function runLokiTurn(input: {
sink?.reset();
try {
const retry = await callModel({
- own: input.own,
+ own: input.own,
feature: "loki-chat",
messages: [
{ role: "system", content: lastSystem },
@@ -605,7 +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,
+ 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/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
index 7c1855a03..b6f31234c 100644
--- a/src/lib/own-model.ts
+++ b/src/lib/own-model.ts
@@ -14,8 +14,7 @@ import { appUrl } from "@/lib/email";
*/
export const OWN_MODEL_KEY_ENV = "BYOK_API_KEY";
-/** Where a user connects, changes or removes their model — the AI tab of Settings. */
-export const OWN_MODEL_SETTINGS_PATH = "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/settings#ai";
+export { OWN_MODEL_SETTINGS_PATH } from "@/lib/own-model-path";
export type OwnModel = {
chain: ChatLink[];
From 42920d4aa0592d829558e797fa2e86d55e19d324 Mon Sep 17 00:00:00 2001
From: Cato
Date: Fri, 25 Sep 2026 17:31:11 +0200
Subject: [PATCH 5/7] fix: narrow userId for the own-model path; document
BYOK_SEAL_SECRET
---
.env.example | 7 +++++++
src/lib/loki-core.ts | 4 +++-
2 files changed, 10 insertions(+), 1 deletion(-)
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/src/lib/loki-core.ts b/src/lib/loki-core.ts
index 114b6e365..ce5efafde 100644
--- a/src/lib/loki-core.ts
+++ b/src/lib/loki-core.ts
@@ -224,7 +224,9 @@ export async function askLoki(message: string, opts?: AskLokiOpts): Promise
Date: Fri, 25 Sep 2026 18:03:55 +0200
Subject: [PATCH 6/7] fix(demo): the shared demo account may not store a model
key
---
src/config/demo.ts | 4 ++++
1 file changed, 4 insertions(+)
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
Date: Fri, 25 Sep 2026 18:16:53 +0200
Subject: [PATCH 7/7] fix(settings): deep links no longer cause a hydration
mismatch
---
src/components/settings/SettingsTabs.tsx | 68 +++++++++++++-----------
1 file changed, 38 insertions(+), 30 deletions(-)
diff --git a/src/components/settings/SettingsTabs.tsx b/src/components/settings/SettingsTabs.tsx
index 09786b37f..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";
@@ -71,9 +71,8 @@ const HASH_TO_TAB: Record = {
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();
@@ -83,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,
@@ -92,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 (