From fb1ae5a522b9921ea8f7d910f9d43feab9573aac Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 28 Jul 2026 14:43:42 +0530 Subject: [PATCH] =?UTF-8?q?feat(byok):=20per-user=20provider=20config=20?= =?UTF-8?q?=E2=80=94=20proxy=20URL=20+=20pinned=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the BYOK settings surface so users can configure their own proxy endpoint + model per provider, not just an API key. Chats that run against any of these keys still skip the aura debit — the debit gate is `usedBYOK`, which stays true across all four slots. Four provider rows on the settings card: | slot | key | base URL | model | | -------------- | --- | ------------ | --------- | | Anthropic | req | — | optional | | OpenAI | req | — | optional | | OpenAI Proxy | req | required | optional | | Ollama Cloud | req | optional | optional | - Prisma: two nullable columns on ModelKey (baseUrl, model). Migration 20260728084341_modelkey_proxy_fields (safe: only adds columns). - Store: adds "openai_proxy" to BYOK_PROVIDERS. Enforces baseUrl on openai_proxy, drops baseUrl silently for providers that don't accept it, always accepts a pinned model. resolveByokForUser walks all four slots with LLM_PROVIDER as an explicit override and a natural priority chain (anthropic → openai_proxy → openai → ollama). - Chat model: four BYOK branches. anthropic/openai stay direct; openai_proxy uses createOpenAI({ baseURL, apiKey }); ollama uses createOpenAICompatible so a user can point at a self-hosted daemon or Ollama Cloud with their own key. - UI: four rows. baseUrl input renders for openai_proxy (required) and ollama (optional). Model input renders for every provider. Saved rows show "url · " and/or "model · " beneath the status pill. - API: existing /api/byok POST already accepts optional baseUrl / model. Store enforces per-provider rules; client-side validation surfaces http(s):// checks and the openai_proxy baseUrl requirement. Fallback behaviour unchanged: if a user has no BYOK row, resolveByokForUser returns null and the platform env path (OPENAI_BASE_URL / *_CHAT_MODEL / etc.) is used with normal aura debit. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/app/api/byok/route.ts | 37 ++++- apps/web/src/lib/byok/store.ts | 153 +++++++++++++++--- apps/web/src/lib/chat-model.ts | 63 ++++++-- apps/web/src/lib/ollama.ts | 3 +- apps/web/src/ui/BYOKSection.tsx | 136 ++++++++++++++-- .../migration.sql | 3 + packages/db/prisma/schema.prisma | 7 + 7 files changed, 353 insertions(+), 49 deletions(-) create mode 100644 packages/db/prisma/migrations/20260728084341_modelkey_proxy_fields/migration.sql diff --git a/apps/web/src/app/api/byok/route.ts b/apps/web/src/app/api/byok/route.ts index ac0b432..5fed7fb 100644 --- a/apps/web/src/app/api/byok/route.ts +++ b/apps/web/src/app/api/byok/route.ts @@ -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(), })), }); @@ -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 }); } @@ -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); diff --git a/apps/web/src/lib/byok/store.ts b/apps/web/src/lib/byok/store.ts index 7025fed..2bf0e60 100644 --- a/apps/web/src/lib/byok/store.ts +++ b/apps/web/src/lib/byok/store.ts @@ -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 }; } @@ -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 { +): Promise { 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 { + 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; } diff --git a/apps/web/src/lib/chat-model.ts b/apps/web/src/lib/chat-model.ts index af7579f..cdba1ae 100644 --- a/apps/web/src/lib/chat-model.ts +++ b/apps/web/src/lib/chat-model.ts @@ -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 @@ -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; diff --git a/apps/web/src/lib/ollama.ts b/apps/web/src/lib/ollama.ts index bb69b3b..f948edd 100644 --- a/apps/web/src/lib/ollama.ts +++ b/apps/web/src/lib/ollama.ts @@ -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"; /** diff --git a/apps/web/src/ui/BYOKSection.tsx b/apps/web/src/ui/BYOKSection.tsx index a4f8ab0..7e4c216 100644 --- a/apps/web/src/ui/BYOKSection.tsx +++ b/apps/web/src/ui/BYOKSection.tsx @@ -10,24 +10,49 @@ import { useEffect, useState } from "react"; const PROVIDERS = [ - { id: "anthropic", label: "Anthropic" }, - { id: "openai", label: "OpenAI" }, - { id: "ollama", label: "Ollama Cloud" }, + { id: "anthropic", label: "Anthropic" }, + { id: "openai", label: "OpenAI" }, + { id: "openai_proxy", label: "OpenAI Proxy" }, + { id: "ollama", label: "Ollama Cloud" }, ] as const; type Provider = (typeof PROVIDERS)[number]["id"]; -type KeyRow = { provider: Provider; last4: string; updatedAt: string }; +// Providers whose row exposes a baseUrl input alongside the key. On +// openai_proxy the URL is required; on ollama it defaults to +// https://ollama.com/v1 when omitted. +const BASEURL_CAPABLE: readonly Provider[] = ["openai_proxy", "ollama"]; +function acceptsBaseUrl(p: Provider): boolean { + return BASEURL_CAPABLE.includes(p); +} +function requiresBaseUrl(p: Provider): boolean { + return p === "openai_proxy"; +} + +type KeyRow = { + provider: Provider; + last4: string; + /** OpenAI only — user-configured proxy base URL. Null when unset. */ + baseUrl: string | null; + /** OpenAI only — user-pinned model id (proxy routing key). Null when unset. */ + model: string | null; + updatedAt: string; +}; export function BYOKSection() { const [keys, setKeys] = useState>({ - anthropic: null, - openai: null, - ollama: null, + anthropic: null, + openai: null, + openai_proxy: null, + ollama: null, }); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); const [draft, setDraft] = useState(""); + // OpenAI-only proxy fields — captured alongside `draft` when the OpenAI + // row is being edited; ignored for the other providers. + const [draftBaseUrl, setDraftBaseUrl] = useState(""); + const [draftModel, setDraftModel] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -42,7 +67,7 @@ export function BYOKSection() { if (!res.ok) return; const body = (await res.json()) as { keys: KeyRow[] }; const map: Record = { - anthropic: null, openai: null, ollama: null, + anthropic: null, openai: null, openai_proxy: null, ollama: null, }; for (const k of body.keys) map[k.provider] = k; setKeys(map); @@ -57,13 +82,30 @@ export function BYOKSection() { setError("Key looks too short"); return; } + const baseUrl = acceptsBaseUrl(provider) ? draftBaseUrl.trim() : ""; + if (baseUrl && !/^https?:\/\//i.test(baseUrl)) { + setError("Base URL must start with http:// or https://"); + return; + } + if (requiresBaseUrl(provider) && !baseUrl) { + setError("OpenAI Proxy requires a base URL"); + return; + } + const model = draftModel.trim(); setBusy(true); setError(null); try { const res = await fetch("/api/byok", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ provider, apiKey: value }), + body: JSON.stringify({ + provider, + apiKey: value, + // Server drops baseUrl for providers that don't accept it. + // Model can be pinned on any provider. + baseUrl: acceptsBaseUrl(provider) ? (baseUrl || null) : null, + model: model || null, + }), }); if (!res.ok) { setError(`Save failed (${res.status})`); @@ -71,6 +113,8 @@ export function BYOKSection() { } setEditing(null); setDraft(""); + setDraftBaseUrl(""); + setDraftModel(""); await refresh(); } finally { setBusy(false); @@ -99,9 +143,11 @@ export function BYOKSection() {
Model keys · BYOK

- Store your own OpenAI, Anthropic, or Ollama Cloud key. Chats - that run against your key skip the aura debit entirely — you - pay the provider directly. + Bring your own model access. Four slots: Anthropic (direct), + OpenAI (direct), OpenAI Proxy (LiteLLM / core-gateway / any + OpenAI-compatible endpoint), and Ollama Cloud or a self-hosted + daemon. Any chat that runs against your key skips the aura + debit entirely — you pay the provider directly.

@@ -122,6 +168,12 @@ export function BYOKSection() { ? `Set · ends in ${key.last4}` : "Not set"} + {key?.baseUrl || key?.model ? ( +
+ {key.baseUrl ? url · {key.baseUrl} : null} + {key.model ? model · {key.model} : null} +
+ ) : null}
{key ? ( @@ -138,8 +190,16 @@ export function BYOKSection() { type="button" disabled={busy} onClick={() => { - setEditing(isEditing ? null : p.id); + const next = isEditing ? null : p.id; + setEditing(next); setDraft(""); + // Prefill proxy fields from the saved row when opening + // the OpenAI editor so the user can tweak just one + // field without retyping everything. + setDraftBaseUrl( + next && acceptsBaseUrl(next) ? key?.baseUrl ?? "" : "", + ); + setDraftModel(next ? key?.model ?? "" : ""); setError(null); }} className="border-2 border-paper/30 px-2.5 py-1 text-[10px] font-bold uppercase tracking-widest hover:bg-white/10 disabled:opacity-40" @@ -160,13 +220,59 @@ export function BYOKSection() { ? "sk-ant-…" : p.id === "openai" ? "sk-…" - : "ollama_…" + : p.id === "openai_proxy" + ? "Proxy security key" + : "ollama_…" } value={draft} onChange={(e) => setDraft(e.target.value)} disabled={busy} className="w-full border-2 border-paper/30 bg-black px-2 py-1.5 font-mono text-xs text-paper placeholder-paper/30 focus:border-paper/60 focus:outline-none" /> + {acceptsBaseUrl(p.id) ? ( + setDraftBaseUrl(e.target.value)} + disabled={busy} + className="w-full border-2 border-paper/30 bg-black px-2 py-1.5 font-mono text-xs text-paper placeholder-paper/30 focus:border-paper/60 focus:outline-none" + /> + ) : null} + setDraftModel(e.target.value)} + disabled={busy} + className="w-full border-2 border-paper/30 bg-black px-2 py-1.5 font-mono text-xs text-paper placeholder-paper/30 focus:border-paper/60 focus:outline-none" + /> +
+ {p.id === "anthropic" && + "Uses api.anthropic.com. Pin a model to override the default."} + {p.id === "openai" && + "Uses api.openai.com. Pin a model to override the default."} + {p.id === "openai_proxy" && + "Sends the key to your proxy as the bearer token. The model id is your proxy's routing key."} + {p.id === "ollama" && + "Defaults to Ollama Cloud. Set a base URL to point at a self-hosted daemon."} +
Stored encrypted at rest. Only the last 4 chars are visible. @@ -178,6 +284,8 @@ export function BYOKSection() { onClick={() => { setEditing(null); setDraft(""); + setDraftBaseUrl(""); + setDraftModel(""); setError(null); }} className="border-2 border-paper/20 px-2.5 py-1 text-[10px] font-bold uppercase tracking-widest text-paper/70 hover:bg-white/5" diff --git a/packages/db/prisma/migrations/20260728084341_modelkey_proxy_fields/migration.sql b/packages/db/prisma/migrations/20260728084341_modelkey_proxy_fields/migration.sql new file mode 100644 index 0000000..4c036f2 --- /dev/null +++ b/packages/db/prisma/migrations/20260728084341_modelkey_proxy_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "ModelKey" ADD COLUMN "baseUrl" TEXT, +ADD COLUMN "model" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8d9b744..4f22078 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -803,6 +803,13 @@ model ModelKey { provider String encryptedKey String last4 String + // Optional OpenAI-compatible proxy. Only meaningful for provider="openai"; + // when set, we route the user's chats through baseUrl using encryptedKey + // as the security key. Model is the exact id sent to the proxy (proxies + // like LiteLLM use it as the routing key; leave null to fall back to the + // platform default). + baseUrl String? + model String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt