Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions apps/web/src/app/api/byok/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export async function GET() {
keys: keys.map((k) => ({
provider: k.provider,
last4: k.last4,
baseUrl: k.baseUrl,
model: k.model,
updatedAt: k.updatedAt.toISOString(),
})),
});
Expand All @@ -37,9 +39,19 @@ export async function POST(req: Request) {
const session = await getSessionFromCookie();
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

let body: { provider?: string; apiKey?: string };
let body: {
provider?: string;
apiKey?: string;
baseUrl?: string | null;
model?: string | null;
};
try {
body = (await req.json()) as { provider?: string; apiKey?: string };
body = (await req.json()) as {
provider?: string;
apiKey?: string;
baseUrl?: string | null;
model?: string | null;
};
} catch {
return NextResponse.json({ error: "bad-json" }, { status: 400 });
}
Expand All @@ -50,9 +62,28 @@ export async function POST(req: Request) {
if (!body.apiKey || typeof body.apiKey !== "string" || body.apiKey.trim().length < 8) {
return NextResponse.json({ error: "bad-key" }, { status: 400 });
}
// Proxy fields only mean something for openai. Validate baseUrl looks
// vaguely like http(s):// so we surface obvious mistakes early; the
// store already coerces empty strings to null.
if (body.baseUrl != null && typeof body.baseUrl !== "string") {
return NextResponse.json({ error: "bad-base-url" }, { status: 400 });
}
if (
typeof body.baseUrl === "string" &&
body.baseUrl.trim() &&
!/^https?:\/\//i.test(body.baseUrl.trim())
) {
return NextResponse.json({ error: "bad-base-url" }, { status: 400 });
}
if (body.model != null && typeof body.model !== "string") {
return NextResponse.json({ error: "bad-model" }, { status: 400 });
}

try {
const saved = await saveModelKey(session.userId, body.provider, body.apiKey);
const saved = await saveModelKey(session.userId, body.provider, body.apiKey, {
baseUrl: body.baseUrl ?? null,
model: body.model ?? null,
});
return NextResponse.json({ provider: saved.provider, last4: saved.last4 });
} catch (err) {
console.error("[byok] save failed", err);
Expand Down
153 changes: 133 additions & 20 deletions apps/web/src/lib/byok/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,77 @@
import { prisma } from "@/lib/db";
import { decryptKey, encryptKey, last4 } from "./encryption";

export const BYOK_PROVIDERS = ["anthropic", "openai", "ollama"] as const;
// "openai_proxy" is stored as its own row because a user may want BOTH a
// direct api.openai.com key AND a proxy configured — they configure them
// separately in the UI and pick a winner via LLM_PROVIDER (or the natural
// preference chain in resolveByokForUser).
export const BYOK_PROVIDERS = [
"anthropic",
"openai",
"openai_proxy",
"ollama",
] as const;
export type BYOKProvider = (typeof BYOK_PROVIDERS)[number];

export function isBYOKProvider(s: string): s is BYOKProvider {
return (BYOK_PROVIDERS as readonly string[]).includes(s);
}

/** Providers that accept a proxy `baseUrl`. Ollama and openai_proxy do by
* definition; anthropic/openai only accept the pinned `model` field. */
function proxyRequired(provider: BYOKProvider): boolean {
return provider === "openai_proxy";
}
function proxyOptional(provider: BYOKProvider): boolean {
return provider === "ollama";
}

export interface SaveModelKeyExtras {
/** Proxy base URL for openai / anthropic (LiteLLM, core-gateway, etc.).
* Ignored for ollama (Ollama BYOK auth is per-request). Falsy/empty
* string clears the stored value. */
baseUrl?: string | null;
/** Pinned model id — proxy routing key when baseUrl is set, or a
* provider-native model override when it isn't. Ignored for ollama.
* Falsy/empty string clears. */
model?: string | null;
}

export async function saveModelKey(
userId: string,
provider: BYOKProvider,
apiKey: string,
extras: SaveModelKeyExtras = {},
): Promise<{ provider: BYOKProvider; last4: string }> {
const trimmed = apiKey.trim();
if (!trimmed) throw new Error("byok: empty key");

const rawBaseUrl =
typeof extras.baseUrl === "string" ? extras.baseUrl.trim() : "";
const rawModel =
typeof extras.model === "string" ? extras.model.trim() : "";

// Enforce baseUrl requirement for openai_proxy — a proxy config with no
// base URL is useless and would silently look identical to a direct
// OpenAI key.
if (proxyRequired(provider) && !rawBaseUrl) {
throw new Error("byok: openai_proxy requires baseUrl");
}

// Only providers that accept baseUrl get to persist one. Everyone can
// pin a model (anthropic / openai use it as a direct model override;
// openai_proxy / ollama use it as the routing key).
const acceptsBaseUrl = proxyRequired(provider) || proxyOptional(provider);
const baseUrl = acceptsBaseUrl && rawBaseUrl ? rawBaseUrl : null;
const model = rawModel || null;

const encryptedKey = encryptKey(trimmed);
const tail = last4(trimmed);

await prisma.modelKey.upsert({
where: { userId_provider: { userId, provider } },
create: { userId, provider, encryptedKey, last4: tail },
update: { encryptedKey, last4: tail },
create: { userId, provider, encryptedKey, last4: tail, baseUrl, model },
update: { encryptedKey, last4: tail, baseUrl, model },
});
return { provider, last4: tail };
}
Expand All @@ -40,58 +90,121 @@ export async function deleteModelKey(
});
}

/** UI shape — the plaintext key never leaves the server. */
/** UI shape — the plaintext key never leaves the server. Returns
* baseUrl + model for the OpenAI row so the settings page can render the
* current proxy config; both are `null` when unset. */
export async function listModelKeysForUser(userId: string): Promise<
Array<{ provider: BYOKProvider; last4: string; updatedAt: Date }>
Array<{
provider: BYOKProvider;
last4: string;
baseUrl: string | null;
model: string | null;
updatedAt: Date;
}>
> {
const rows = await prisma.modelKey.findMany({
where: { userId },
select: { provider: true, last4: true, updatedAt: true },
select: {
provider: true,
last4: true,
baseUrl: true,
model: true,
updatedAt: true,
},
orderBy: { provider: "asc" },
});
return rows.filter((r) => isBYOKProvider(r.provider)) as Array<{
provider: BYOKProvider;
last4: string;
baseUrl: string | null;
model: string | null;
updatedAt: Date;
}>;
}

/** Server-only helper: decrypts and returns the plaintext key for the
* given (user, provider), or `null` if none is stored. Called from the
* chat model resolver and never returned to the client. */
export interface PlaintextKey {
apiKey: string;
/** Proxy base URL when the user configured one (openai / anthropic). */
baseUrl: string | null;
/** Pinned model id when the user set one (openai / anthropic). */
model: string | null;
}

/** Server-only helper: decrypts and returns the plaintext key + optional
* proxy config for the given (user, provider), or `null` if none is
* stored. Called from the chat model resolver and never returned to the
* client. */
export async function getPlaintextModelKey(
userId: string,
provider: BYOKProvider,
): Promise<string | null> {
): Promise<PlaintextKey | null> {
const row = await prisma.modelKey.findUnique({
where: { userId_provider: { userId, provider } },
select: { encryptedKey: true },
select: { encryptedKey: true, baseUrl: true, model: true },
});
if (!row) return null;
try {
return decryptKey(row.encryptedKey);
return {
apiKey: decryptKey(row.encryptedKey),
baseUrl: row.baseUrl,
model: row.model,
};
} catch (err) {
console.warn("[byok] decrypt failed for", userId, provider, err);
return null;
}
}

export interface ResolvedByok {
provider: BYOKProvider;
apiKey: string;
/** Set when the user configured a proxy for this provider. */
baseUrl?: string | null;
/** Set when the user pinned a specific model id. */
model?: string | null;
}

/** Pick the best BYOK key for a user given the platform's provider
* preference (LLM_PROVIDER env). Returns `null` when the user has no
* keys or Ollama-only (Ollama BYOK isn't wired into getChatModel yet).
* Called from chat routes to decide whether the town owner is
* keys. Called from chat routes to decide whether the town owner is
* self-paying this turn. */
export async function resolveByokForUser(
userId: string,
): Promise<{ provider: BYOKProvider; apiKey: string } | null> {
const [anthropicKey, openaiKey] = await Promise.all([
): Promise<ResolvedByok | null> {
const [anthropicKey, openaiKey, openaiProxyKey, ollamaKey] = await Promise.all([
getPlaintextModelKey(userId, "anthropic"),
getPlaintextModelKey(userId, "openai"),
getPlaintextModelKey(userId, "openai_proxy"),
getPlaintextModelKey(userId, "ollama"),
]);
const toResolved = (
provider: BYOKProvider,
row: PlaintextKey,
): ResolvedByok => ({
provider,
apiKey: row.apiKey,
baseUrl: row.baseUrl,
model: row.model,
});

const explicit = (process.env.LLM_PROVIDER ?? "").toLowerCase().trim();
if (explicit === "openai" && openaiKey) return { provider: "openai", apiKey: openaiKey };
if (explicit === "anthropic" && anthropicKey) return { provider: "anthropic", apiKey: anthropicKey };
if (anthropicKey) return { provider: "anthropic", apiKey: anthropicKey };
if (openaiKey) return { provider: "openai", apiKey: openaiKey };

// Explicit LLM_PROVIDER match wins if the corresponding key is stored.
// For "openai" we prefer the proxy variant if configured — proxy is a
// deliberate override; the direct openai row acts as a fallback.
if (explicit === "openai" && openaiProxyKey)
return toResolved("openai_proxy", openaiProxyKey);
if (explicit === "openai" && openaiKey) return toResolved("openai", openaiKey);
if (explicit === "anthropic" && anthropicKey)
return toResolved("anthropic", anthropicKey);
if (explicit === "ollama" && ollamaKey) return toResolved("ollama", ollamaKey);

// No/unmatched explicit — anthropic first, then openai_proxy (if the
// user configured one they clearly intended to use it), then plain
// openai, then ollama.
if (anthropicKey) return toResolved("anthropic", anthropicKey);
if (openaiProxyKey) return toResolved("openai_proxy", openaiProxyKey);
if (openaiKey) return toResolved("openai", openaiKey);
if (ollamaKey) return toResolved("ollama", ollamaKey);
return null;
}
63 changes: 52 additions & 11 deletions apps/web/src/lib/chat-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@

import { anthropic, createAnthropic } from "@ai-sdk/anthropic";
import { openai, createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";
import { hasOllama, ollamaModel } from "@/lib/ollama";
import {
DEFAULT_OLLAMA_MODEL,
DEFAULT_OLLAMA_BASE_URL,
hasOllama,
ollamaModel,
} from "@/lib/ollama";
import type { BYOKProvider } from "@/lib/byok/store";

const ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
const ANTHROPIC_MODEL_DEFAULT = "claude-haiku-4-5-20251001";
const ANTHROPIC_MODEL = ANTHROPIC_MODEL_DEFAULT;
// Fallback OpenAI chat model. Platform path (OPENAI_API_KEY) can override
// via OPENAI_CHAT_MODEL — useful for proxies where the model name is the
// routing key, e.g. "openai/claude-sonnet-4-6" via LiteLLM. BYOK always
Expand All @@ -30,24 +37,58 @@ export interface GetChatModelResult {
}

export function getChatModel(
opts?: { userKey?: { provider: BYOKProvider; apiKey: string } },
opts?: {
userKey?: {
provider: BYOKProvider;
apiKey: string;
/** Proxy base URL (openai_proxy: required, ollama: optional). Ignored
* by anthropic / openai — those providers only honor `model`. */
baseUrl?: string | null;
/** Pinned model id — proxy routing key for openai_proxy / ollama,
* provider-native model override for anthropic / openai. */
model?: string | null;
};
},
): GetChatModelResult {
const userKey = opts?.userKey;

if (userKey?.provider === "anthropic" && userKey.apiKey) {
// Direct api.anthropic.com — honor a pinned model id, else default.
const client = createAnthropic({ apiKey: userKey.apiKey });
return { model: client(ANTHROPIC_MODEL), usedBYOK: true };
const modelName = userKey.model?.trim() || ANTHROPIC_MODEL_DEFAULT;
return { model: client(modelName), usedBYOK: true };
}
if (userKey?.provider === "openai" && userKey.apiKey) {
// BYOK hits api.openai.com with the user's key — must be a real OpenAI
// model id, never the OPENAI_CHAT_MODEL override (which may be a
// proxy-prefixed alias like "openai/claude-sonnet-4-6").
// Direct api.openai.com — honor a pinned model, else default. If the
// user needs a proxy they should use the "openai_proxy" row instead.
const client = createOpenAI({ apiKey: userKey.apiKey });
return { model: client(OPENAI_MODEL_DEFAULT), usedBYOK: true };
const modelName = userKey.model?.trim() || OPENAI_MODEL_DEFAULT;
return { model: client(modelName), usedBYOK: true };
}
if (userKey?.provider === "openai_proxy" && userKey.apiKey) {
// OpenAI-compatible proxy (LiteLLM, core-gateway, Vercel AI Gateway…).
// baseUrl is required by the store; assert here as a defense-in-depth
// check so a legacy row without it doesn't silently fall through.
const proxyBase = userKey.baseUrl?.trim();
if (!proxyBase) throw new Error("openai_proxy BYOK missing baseUrl");
const client = createOpenAI({ baseURL: proxyBase, apiKey: userKey.apiKey });
const modelName = userKey.model?.trim() || OPENAI_MODEL_DEFAULT;
return { model: client(modelName), usedBYOK: true };
}
if (userKey?.provider === "ollama" && userKey.apiKey) {
// Ollama Cloud (or a self-hosted daemon behind a bearer). Uses
// OpenAI-compatible transport under the hood, same as the platform
// Ollama path — but scoped to the user's own key + endpoint.
const provider = createOpenAICompatible({
name: "ollama",
baseURL: userKey.baseUrl?.trim() || DEFAULT_OLLAMA_BASE_URL,
apiKey: userKey.apiKey,
supportsStructuredOutputs: true,
includeUsage: true,
});
const modelName = userKey.model?.trim() || DEFAULT_OLLAMA_MODEL;
return { model: provider(modelName), usedBYOK: true };
}
// Ollama BYOK isn't wired here yet — Ollama Cloud auth is per-request,
// and the existing `ollamaModel()` reads OLLAMA_API_KEY directly. Fall
// through to platform behaviour for now.

const hasAnthropic = !!process.env.ANTHROPIC_API_KEY;
const hasOpenAI = !!process.env.OPENAI_API_KEY;
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";

const DEFAULT_BASE_URL = "https://ollama.com/v1";
export const DEFAULT_OLLAMA_BASE_URL = "https://ollama.com/v1";
const DEFAULT_BASE_URL = DEFAULT_OLLAMA_BASE_URL;
export const DEFAULT_OLLAMA_MODEL = "gpt-oss:120b-cloud";

/**
Expand Down
Loading