feat(den): add org-managed llm provider library (#1343)

* feat(den): add org-managed llm provider library

Let Den admins curate shared providers and models with encrypted credentials, then let the app connect through the existing add-provider flow. This keeps org-wide model access consistent without requiring per-user OAuth setup.

* docs(den): prefer longer db encryption keys

* fix(den): pass db encryption key through local dev

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-06 10:17:21 -07:00
committed by GitHub
parent b3afb8a176
commit 0589897b2f
40 changed files with 5714 additions and 91 deletions

View File

@@ -900,12 +900,14 @@ export default function App() {
providerAuthBusy,
providerAuthError,
providerAuthMethods,
providerAuthProviders,
providerAuthPreferredProviderId,
providerAuthWorkerType,
startProviderAuth,
refreshProviders,
completeProviderAuthOAuth,
submitProviderApiKey,
connectCloudProvider,
disconnectProvider,
openProviderAuthModal,
closeProviderAuthModal,
@@ -2046,6 +2048,7 @@ export default function App() {
providerAuthModalOpen: providerAuthModalOpen(),
providerAuthError: providerAuthError(),
providerAuthMethods: providerAuthMethods(),
providerAuthProviders: providerAuthProviders(),
providerAuthPreferredProviderId: providerAuthPreferredProviderId(),
providerAuthWorkerType: providerAuthWorkerType(),
openProviderAuthModal,
@@ -2055,6 +2058,7 @@ export default function App() {
completeProviderAuthOAuth,
refreshProviders,
submitProviderApiKey,
connectCloudProvider,
setView,
toggleSettings: () => toggleSettingsView("general"),
startupPreference: startupPreference(),
@@ -2270,12 +2274,14 @@ export default function App() {
completeProviderAuthOAuth: completeProviderAuthOAuth,
refreshProviders: refreshProviders,
submitProviderApiKey: submitProviderApiKey,
connectCloudProvider: connectCloudProvider,
openProviderAuthModal: openProviderAuthModal,
closeProviderAuthModal: closeProviderAuthModal,
providerAuthModalOpen: providerAuthModalOpen(),
providerAuthBusy: providerAuthBusy(),
providerAuthError: providerAuthError(),
providerAuthMethods: providerAuthMethods(),
providerAuthProviders: providerAuthProviders(),
providerAuthPreferredProviderId: providerAuthPreferredProviderId(),
providers: providers(),
providerConnectedIds: providerConnectedIds(),

View File

@@ -1,3 +1,3 @@
export { createProvidersStore } from "./store";
export type { ProviderAuthMethod, ProviderOAuthStartResult } from "./store";
export type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult } from "./store";
export { default as ProviderAuthModal } from "./provider-auth-modal";

View File

@@ -1,13 +1,12 @@
import { CheckCircle2, Loader2, X, Search, ChevronRight } from "lucide-solid";
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
import type { ProviderListItem } from "../../types";
import { isTauriRuntime } from "../../utils";
import { compareProviders } from "../../utils/providers";
import Button from "../../components/button";
import ProviderIcon from "../../components/provider-icon";
import TextInput from "../../components/text-input";
import type { ProviderAuthMethod, ProviderOAuthStartResult } from "./store";
import type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult } from "./store";
type ProviderAuthEntry = {
id: string;
@@ -37,11 +36,12 @@ export type ProviderAuthModalProps = {
error: string | null;
preferredProviderId?: string | null;
workerType?: "local" | "remote";
providers: ProviderListItem[];
providers: ProviderAuthProvider[];
connectedProviderIds: string[];
authMethods: Record<string, ProviderAuthMethod[]>;
onSelect: (providerId: string, methodIndex?: number) => Promise<ProviderOAuthStartResult>;
onSubmitApiKey: (providerId: string, apiKey: string) => Promise<string | void>;
onConnectCloudProvider: (cloudProviderId: string) => Promise<string | void>;
onSubmitOAuth: (
providerId: string,
methodIndex: number,
@@ -121,8 +121,9 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
const actionDisabled = () => props.loading || props.submitting;
const [view, setView] = createSignal<"list" | "method" | "api" | "oauth-code" | "oauth-auto">("list");
const [view, setView] = createSignal<"list" | "method" | "api" | "cloud" | "oauth-code" | "oauth-auto">("list");
const [selectedProviderId, setSelectedProviderId] = createSignal<string | null>(null);
const [selectedCloudMethod, setSelectedCloudMethod] = createSignal<ProviderAuthMethod | null>(null);
const [apiKeyInput, setApiKeyInput] = createSignal("");
const [oauthCodeInput, setOauthCodeInput] = createSignal("");
const [oauthSession, setOauthSession] = createSignal<ProviderOAuthSession | null>(null);
@@ -187,6 +188,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
}
setView("list");
setSelectedProviderId(null);
setSelectedCloudMethod(null);
setApiKeyInput("");
setOauthCodeInput("");
setOauthSession(null);
@@ -462,12 +464,19 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
const entry = selectedEntry();
if (!entry || actionDisabled()) return;
setLocalError(null);
setSelectedCloudMethod(null);
if (method.type === "oauth") {
await startOauth(entry, method.methodIndex);
return;
}
if (method.type === "cloud") {
setSelectedCloudMethod(method);
setView("cloud");
return;
}
setView("api");
};
@@ -490,6 +499,19 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
}
};
const handleCloudSubmit = async () => {
const method = selectedCloudMethod();
if (!method?.cloudProviderId || actionDisabled()) return;
setLocalError(null);
try {
await props.onConnectCloudProvider(method.cloudProviderId);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to connect organization provider";
setLocalError(message);
}
};
const handleOauthCodeSubmit = async () => {
const entry = selectedEntry();
const session = oauthSession();
@@ -521,16 +543,24 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
if (resolvedView() === "api" && (selectedEntry()?.methods.length ?? 0) > 1) {
setView("method");
setSelectedCloudMethod(null);
setApiKeyInput("");
setLocalError(null);
return;
}
if (resolvedView() === "cloud" && (selectedEntry()?.methods.length ?? 0) > 1) {
setView("method");
setSelectedCloudMethod(null);
setLocalError(null);
return;
}
resetState();
};
const submittingLabel = () => {
if (!props.submitting) return null;
if (resolvedView() === "api") return "Saving API key...";
if (resolvedView() === "cloud") return "Connecting organization provider...";
if (resolvedView() === "oauth-code") return "Verifying authorization code...";
if (resolvedView() === "oauth-auto") return "Waiting for OAuth confirmation...";
return "Opening authentication...";
@@ -584,6 +614,9 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
if (method.type === "oauth") {
return "Continue in the browser and let OpenWork finish the connection automatically.";
}
if (method.type === "cloud") {
return method.description ?? "Use the provider and credential managed by your organization.";
}
return "Paste a secret key that OpenWork stores locally on this device.";
};
@@ -594,7 +627,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
<div class="px-6 pt-6 pb-4 border-b border-gray-6/50 flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-gray-12">Connect providers</h3>
<p class="text-sm text-gray-11 mt-1">Sign in to services you want OpenWork to use.</p>
<p class="text-sm text-gray-11 mt-1">Sign in to services or use providers managed by your organization.</p>
</div>
<Button
variant="ghost"
@@ -704,6 +737,8 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
class={`text-[10px] font-medium px-2 py-0.5 rounded-md border ${
method.type === "oauth"
? "bg-indigo-3/30 text-indigo-11 border-indigo-5/30"
: method.type === "cloud"
? "bg-emerald-3/30 text-emerald-11 border-emerald-5/30"
: "bg-gray-3/40 text-gray-11 border-gray-6/40"
}`}
>
@@ -801,6 +836,41 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {
</div>
</Show>
<Show when={resolvedView() === "cloud" && selectedEntry() && selectedCloudMethod()}>
<div class="rounded-xl border border-gray-6/40 bg-gray-2/50 shadow-sm p-5 space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-medium text-gray-12">{selectedEntry()!.name}</div>
<div class="text-xs text-gray-10 mt-1">Connect with the provider managed by your organization.</div>
</div>
<Button variant="ghost" onClick={handleBack} disabled={actionDisabled()}>
Back
</Button>
</div>
<div class="text-xs text-gray-9">
{selectedCloudMethod()!.description ?? "Use the provider and credential managed by your organization."}
</div>
<Show when={(selectedCloudMethod()!.modelCount ?? 0) > 0}>
<div class="rounded-lg border border-gray-6/60 bg-gray-1/60 px-3 py-2 text-[11px] text-gray-9">
{(selectedCloudMethod()!.modelCount ?? 0)} curated model{(selectedCloudMethod()!.modelCount ?? 0) === 1 ? "" : "s"} will be added to this workspace.
</div>
</Show>
<Show when={(selectedCloudMethod()!.env?.length ?? 0) > 0}>
<div class="text-[11px] text-gray-9">
Env vars: <span class="font-mono">{selectedCloudMethod()!.env!.join(", ")}</span>
</div>
</Show>
<div class="flex items-center justify-between gap-3">
<div class="text-[11px] text-gray-9">
OpenWork will install the provider config and use the credential stored for your org.
</div>
<Button variant="secondary" onClick={handleCloudSubmit} disabled={actionDisabled()}>
{props.submitting ? "Connecting..." : "Connect provider"}
</Button>
</div>
</div>
</Show>
<Show when={resolvedView() === "oauth-code" && selectedEntry() && oauthSession()}>
<div class="rounded-xl border border-gray-6/40 bg-gray-2/50 shadow-sm p-5 space-y-4">
<div class="flex items-center justify-between gap-4">

View File

@@ -1,19 +1,30 @@
import { createMemo, createSignal, type Accessor } from "solid-js";
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js";
import type { ProviderAuthAuthorization, ProviderListResponse } from "@opencode-ai/sdk/v2/client";
import type { ProviderAuthAuthorization, ProviderConfig, ProviderListResponse } from "@opencode-ai/sdk/v2/client";
import { t } from "../../../i18n";
import { createDenClient, readDenSettings, type DenOrgLlmProvider, type DenOrgLlmProviderConnection } from "../../lib/den";
import { unwrap, waitForHealthy } from "../../lib/opencode";
import type { Client, ProviderListItem, WorkspaceDisplay } from "../../types";
import { safeStringify } from "../../utils";
import { filterProviderList, mapConfigProvidersToList } from "../../utils/providers";
import { compareProviders, filterProviderList, mapConfigProvidersToList } from "../../utils/providers";
type ProviderReturnFocusTarget = "none" | "composer";
export type ProviderAuthMethod = {
type: "oauth" | "api";
type: "oauth" | "api" | "cloud";
label: string;
methodIndex?: number;
cloudProviderId?: string;
description?: string;
env?: string[];
modelCount?: number;
};
export type ProviderAuthProvider = {
id: string;
name: string;
env: string[];
};
export type ProviderOAuthStartResult = {
@@ -44,11 +55,183 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) {
const [providerAuthPreferredProviderId, setProviderAuthPreferredProviderId] = createSignal<string | null>(null);
const [providerAuthReturnFocusTarget, setProviderAuthReturnFocusTarget] =
createSignal<ProviderReturnFocusTarget>("none");
const [cloudOrgProviders, setCloudOrgProviders] = createSignal<DenOrgLlmProvider[]>([]);
let cloudOrgProvidersLoadKey = "";
const getStringList = (value: unknown) =>
Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
: [];
const getCloudProviderEnv = (config: Record<string, unknown>) => getStringList(config.env);
const buildCloudProviderMethod = (provider: DenOrgLlmProvider): ProviderAuthMethod => ({
type: "cloud",
label:
provider.name.trim().toLowerCase() === provider.providerId.trim().toLowerCase()
? "Use organization provider"
: `Use ${provider.name}`,
cloudProviderId: provider.id,
description:
provider.models.length > 0
? `${provider.models.length} curated model${provider.models.length === 1 ? "" : "s"} managed by your organization.`
: "Use the provider and credential managed by your organization.",
env: getCloudProviderEnv(provider.providerConfig),
modelCount: provider.models.length,
});
const buildCloudProviderConfig = (
provider: DenOrgLlmProviderConnection,
): ProviderConfig => {
const models = Object.fromEntries(
provider.models.map((model) => {
const next: NonNullable<ProviderConfig["models"]>[string] = {
id: model.id,
name: model.name,
};
const raw = model.config;
for (const key of [
"family",
"release_date",
"attachment",
"reasoning",
"temperature",
"tool_call",
"interleaved",
"cost",
"limit",
"modalities",
"status",
"options",
"headers",
"provider",
"variants",
] as const) {
const value = raw[key];
if (value !== undefined) {
(next as Record<string, unknown>)[key] = value;
}
}
return [model.id, next];
}),
);
const next: ProviderConfig = {
id: provider.providerId,
name: provider.name,
env: getCloudProviderEnv(provider.providerConfig),
models,
};
if (typeof provider.providerConfig.npm === "string" && provider.providerConfig.npm.trim()) {
next.npm = provider.providerConfig.npm;
}
if (typeof provider.providerConfig.api === "string" && provider.providerConfig.api.trim()) {
next.api = provider.providerConfig.api;
}
if (provider.providerConfig.options && typeof provider.providerConfig.options === "object") {
next.options = provider.providerConfig.options as Record<string, unknown>;
}
if (Array.isArray(provider.providerConfig.whitelist)) {
next.whitelist = getStringList(provider.providerConfig.whitelist);
}
if (Array.isArray(provider.providerConfig.blacklist)) {
next.blacklist = getStringList(provider.providerConfig.blacklist);
}
return next;
};
const providerAuthWorkerType = createMemo<"local" | "remote">(() =>
options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local",
);
const providerAuthProviders = createMemo<ProviderAuthProvider[]>(() => {
const merged = new Map<string, ProviderAuthProvider>();
for (const provider of options.providers()) {
const id = provider.id?.trim();
if (!id) continue;
merged.set(id, {
id,
name: provider.name?.trim() || id,
env: Array.isArray(provider.env) ? provider.env : [],
});
}
for (const provider of cloudOrgProviders()) {
const id = provider.providerId.trim();
if (!id || merged.has(id)) continue;
merged.set(id, {
id,
name: provider.name.trim() || id,
env: getCloudProviderEnv(provider.providerConfig),
});
}
return [...merged.values()].sort(compareProviders);
});
const getCloudOrgProvidersKey = () => {
const settings = readDenSettings();
return [
settings.baseUrl,
settings.apiBaseUrl ?? "",
settings.activeOrgId?.trim() ?? "",
settings.authToken?.trim() ?? "",
].join("::");
};
const refreshCloudOrgProviders = async (optionsArg?: { force?: boolean }) => {
const settings = readDenSettings();
const loadKey = getCloudOrgProvidersKey();
const token = settings.authToken?.trim() ?? "";
const orgId = settings.activeOrgId?.trim() ?? "";
if (!optionsArg?.force && cloudOrgProvidersLoadKey === loadKey) {
return cloudOrgProviders();
}
if (!token || !orgId) {
setCloudOrgProviders([]);
cloudOrgProvidersLoadKey = loadKey;
return [];
}
const client = createDenClient({
baseUrl: settings.baseUrl,
token,
});
try {
const providers = await client.listOrgLlmProviders(orgId);
setCloudOrgProviders(providers);
cloudOrgProvidersLoadKey = loadKey;
return providers;
} catch (error) {
setCloudOrgProviders([]);
cloudOrgProvidersLoadKey = "";
throw error;
}
};
createEffect(() => {
if (typeof window === "undefined") {
return;
}
const handleDenSessionUpdate = () => {
cloudOrgProvidersLoadKey = "";
setCloudOrgProviders([]);
setProviderAuthMethods({});
};
window.addEventListener("openwork-den-session-updated", handleDenSessionUpdate as EventListener);
onCleanup(() => {
window.removeEventListener("openwork-den-session-updated", handleDenSessionUpdate as EventListener);
});
});
const applyProviderListState = (value: ProviderListResponse) => {
options.setProviders(value.all ?? []);
options.setProviderDefaults(value.default ?? {});
@@ -149,8 +332,9 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) {
const buildProviderAuthMethods = (
methods: Record<string, ProviderAuthMethod[]>,
availableProviders: ProviderListItem[],
availableProviders: ProviderAuthProvider[],
workerType: "local" | "remote",
cloudProviders: DenOrgLlmProvider[],
) => {
const merged = Object.fromEntries(
Object.entries(methods ?? {}).map(([id, providerMethods]) => [
@@ -182,6 +366,17 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) {
return workerType === "remote" ? isHeadless : !isHeadless;
});
}
for (const provider of cloudProviders) {
const id = provider.providerId.trim();
if (!id) continue;
const existing = merged[id] ?? [];
if (existing.some((method) => method.type === "cloud" && method.cloudProviderId === provider.id)) {
continue;
}
merged[id] = [...existing, buildCloudProviderMethod(provider)];
}
return merged;
};
@@ -191,10 +386,14 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) {
throw new Error(t("providers.not_connected"));
}
const methods = unwrap(await c.provider.auth());
const cloudProviders = await refreshCloudOrgProviders().catch(
() => [] as DenOrgLlmProvider[],
);
return buildProviderAuthMethods(
methods as Record<string, ProviderAuthMethod[]>,
options.providers(),
providerAuthProviders(),
workerType,
cloudProviders,
);
};
@@ -405,6 +604,69 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) {
}
}
async function connectCloudProvider(cloudProviderId: string) {
setProviderAuthError(null);
const c = options.client();
if (!c) {
throw new Error(t("providers.not_connected"));
}
const settings = readDenSettings();
const token = settings.authToken?.trim() ?? "";
const orgId = settings.activeOrgId?.trim() ?? "";
if (!token || !orgId) {
throw new Error("Sign in to OpenWork Cloud and choose an organization first.");
}
try {
const den = createDenClient({
baseUrl: settings.baseUrl,
token,
});
const provider = await den.getOrgLlmProviderConnection(orgId, cloudProviderId);
const apiKey = provider.apiKey?.trim() ?? "";
const env = getCloudProviderEnv(provider.providerConfig);
if (!apiKey && env.length > 0) {
throw new Error(`${provider.name} does not have a stored organization credential yet.`);
}
if (apiKey) {
await c.auth.set({
providerID: provider.providerId,
auth: {
type: "api",
key: apiKey,
},
});
}
const config = unwrap(await c.config.get());
const disabledProviders = Array.isArray(config.disabled_providers)
? config.disabled_providers
: [];
const nextDisabledProviders = disabledProviders.filter((id) => id !== provider.providerId);
await c.config.update({
config: {
...config,
disabled_providers: nextDisabledProviders,
provider: {
...(config.provider ?? {}),
[provider.providerId]: buildCloudProviderConfig(provider),
},
},
});
options.setDisabledProviders(nextDisabledProviders);
await refreshProviders({ dispose: true });
return `${t("status.connected")} ${provider.name}`;
} catch (error) {
const message = describeProviderError(error, "Failed to connect organization provider.");
setProviderAuthError(message);
throw error instanceof Error ? error : new Error(message);
}
}
async function disconnectProvider(providerId: string) {
setProviderAuthError(null);
const c = options.client();
@@ -552,10 +814,12 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) {
providerAuthMethods,
providerAuthPreferredProviderId,
providerAuthWorkerType,
providerAuthProviders,
startProviderAuth,
refreshProviders,
completeProviderAuthOAuth,
submitProviderApiKey,
connectCloudProvider,
disconnectProvider,
openProviderAuthModal,
closeProviderAuthModal,

View File

@@ -81,6 +81,29 @@ export type DenTemplate = {
creator: DenTemplateCreator | null;
};
export type DenOrgLlmProviderModel = {
id: string;
name: string;
config: Record<string, unknown>;
createdAt: string | null;
};
export type DenOrgLlmProvider = {
id: string;
source: "models_dev" | "custom";
providerId: string;
name: string;
providerConfig: Record<string, unknown>;
hasApiKey: boolean;
models: DenOrgLlmProviderModel[];
createdAt: string | null;
updatedAt: string | null;
};
export type DenOrgLlmProviderConnection = DenOrgLlmProvider & {
apiKey: string | null;
};
export type DenBillingPrice = {
amount: number | null;
currency: string | null;
@@ -554,6 +577,71 @@ function getDenOrgSkillHubsFromPayload(payload: unknown): DenOrgSkillHubParsed[]
.filter((e): e is DenOrgSkillHubParsed => e !== null);
}
function parseDenOrgLlmProviderModel(value: unknown): DenOrgLlmProviderModel | null {
if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string") {
return null;
}
return {
id: value.id,
name: value.name,
config: isRecord(value.config) ? value.config : {},
createdAt: typeof value.createdAt === "string" ? value.createdAt : null,
};
}
function parseDenOrgLlmProvider(value: unknown): DenOrgLlmProvider | null {
if (
!isRecord(value) ||
typeof value.id !== "string" ||
typeof value.providerId !== "string" ||
typeof value.name !== "string" ||
(value.source !== "models_dev" && value.source !== "custom")
) {
return null;
}
return {
id: value.id,
source: value.source,
providerId: value.providerId,
name: value.name,
providerConfig: isRecord(value.providerConfig) ? value.providerConfig : {},
hasApiKey: value.hasApiKey === true,
models: Array.isArray(value.models)
? value.models.map(parseDenOrgLlmProviderModel).filter((entry): entry is DenOrgLlmProviderModel => entry !== null)
: [],
createdAt: typeof value.createdAt === "string" ? value.createdAt : null,
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null,
};
}
function getDenOrgLlmProviders(payload: unknown): DenOrgLlmProvider[] {
if (!isRecord(payload) || !Array.isArray(payload.llmProviders)) {
return [];
}
return payload.llmProviders
.map(parseDenOrgLlmProvider)
.filter((entry): entry is DenOrgLlmProvider => entry !== null);
}
function getDenOrgLlmProviderConnection(payload: unknown): DenOrgLlmProviderConnection | null {
if (!isRecord(payload) || !payload.llmProvider) {
return null;
}
const provider = parseDenOrgLlmProvider(payload.llmProvider);
if (!provider || !isRecord(payload.llmProvider)) {
return null;
}
return {
...provider,
apiKey: typeof payload.llmProvider.apiKey === "string" ? payload.llmProvider.apiKey : null,
};
}
function getBillingPrice(value: unknown): DenBillingPrice | null {
if (!isRecord(value)) {
return null;
@@ -946,6 +1034,30 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul
);
},
async listOrgLlmProviders(orgId: string): Promise<DenOrgLlmProvider[]> {
const payload = await requestJson<unknown>(baseUrls, `/v1/orgs/${encodeURIComponent(orgId)}/llm-providers`, {
method: "GET",
token,
});
return getDenOrgLlmProviders(payload);
},
async getOrgLlmProviderConnection(orgId: string, llmProviderId: string): Promise<DenOrgLlmProviderConnection> {
const payload = await requestJson<unknown>(
baseUrls,
`/v1/orgs/${encodeURIComponent(orgId)}/llm-providers/${encodeURIComponent(llmProviderId)}/connect`,
{
method: "GET",
token,
},
);
const provider = getDenOrgLlmProviderConnection(payload);
if (!provider) {
throw new DenApiError(500, "invalid_llm_provider_payload", "LLM provider response was missing connection details.");
}
return provider;
},
async getBillingStatus(options: { includeCheckout?: boolean; includePortal?: boolean; includeInvoices?: boolean } = {}): Promise<DenBillingSummary> {
const params = new URLSearchParams();
if (options.includeCheckout) {

View File

@@ -66,6 +66,7 @@ import Button from "../components/button";
import ConfirmModal from "../components/confirm-modal";
import RenameSessionModal from "../components/rename-session-modal";
import { ProviderAuthModal,
type ProviderAuthProvider,
type ProviderAuthMethod,
type ProviderOAuthStartResult,
} from "../context/providers";
@@ -219,6 +220,7 @@ export type SessionViewProps = {
providerId: string,
apiKey: string,
) => Promise<string | void>;
connectCloudProvider: (cloudProviderId: string) => Promise<string | void>;
refreshProviders: () => Promise<unknown>;
openProviderAuthModal: (options?: {
returnFocusTarget?: "none" | "composer";
@@ -229,6 +231,7 @@ export type SessionViewProps = {
providerAuthBusy: boolean;
providerAuthError: string | null;
providerAuthMethods: Record<string, ProviderAuthMethod[]>;
providerAuthProviders: ProviderAuthProvider[];
providerAuthPreferredProviderId: string | null;
providerAuthWorkerType: "local" | "remote";
providers: ProviderListItem[];
@@ -2456,6 +2459,21 @@ export default function SessionView(props: SessionViewProps) {
}
};
const handleCloudProviderConnect = async (cloudProviderId: string) => {
if (providerAuthActionBusy()) return;
setProviderAuthActionBusy(true);
try {
const message = await props.connectCloudProvider(cloudProviderId);
showStatusToast(message || t("session.provider_connected"), "success");
props.closeProviderAuthModal();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to connect organization provider";
showStatusToast(message, "error");
} finally {
setProviderAuthActionBusy(false);
}
};
const handleSendPrompt = (draft: ComposerDraft) => {
suppressJumpControlsTemporarily();
sessionScroll.scrollToBottom();
@@ -3726,11 +3744,12 @@ export default function SessionView(props: SessionViewProps) {
error={props.providerAuthError}
preferredProviderId={props.providerAuthPreferredProviderId}
workerType={props.providerAuthWorkerType}
providers={props.providers}
providers={props.providerAuthProviders}
connectedProviderIds={props.providerConnectedIds}
authMethods={props.providerAuthMethods}
onSelect={handleProviderAuthSelect}
onSubmitApiKey={handleProviderAuthApiKey}
onConnectCloudProvider={handleCloudProviderConnect}
onSubmitOAuth={handleProviderAuthOAuth}
onRefreshProviders={props.refreshProviders}
onClose={() => props.closeProviderAuthModal()}

View File

@@ -49,6 +49,7 @@ import Button from "../components/button";
import SettingsView from "../pages/settings";
import StatusBar from "../components/status-bar";
import { ProviderAuthModal,
type ProviderAuthProvider,
type ProviderAuthMethod,
type ProviderOAuthStartResult,
} from "../context/providers";
@@ -77,6 +78,7 @@ export type SettingsShellProps = {
providerAuthModalOpen: boolean;
providerAuthError: string | null;
providerAuthMethods: Record<string, ProviderAuthMethod[]>;
providerAuthProviders: ProviderAuthProvider[];
providerAuthPreferredProviderId: string | null;
providerAuthWorkerType: "local" | "remote";
openProviderAuthModal: (options?: {
@@ -92,6 +94,7 @@ export type SettingsShellProps = {
code?: string
) => Promise<{ connected: boolean; pending?: boolean; message?: string }>;
submitProviderApiKey: (providerId: string, apiKey: string) => Promise<string | void>;
connectCloudProvider: (cloudProviderId: string) => Promise<string | void>;
refreshProviders: () => Promise<unknown>;
setView: (view: View, sessionId?: string) => void;
toggleSettings: () => void;
@@ -383,6 +386,19 @@ export default function SettingsShell(props: SettingsShellProps) {
}
};
const handleCloudProviderConnect = async (cloudProviderId: string) => {
if (providerAuthActionBusy()) return;
setProviderAuthActionBusy(true);
try {
await props.connectCloudProvider(cloudProviderId);
props.closeProviderAuthModal();
} catch {
// Errors are surfaced in the modal.
} finally {
setProviderAuthActionBusy(false);
}
};
onCleanup(() => {
// no-op
});
@@ -1267,11 +1283,12 @@ export default function SettingsShell(props: SettingsShellProps) {
error={props.providerAuthError}
preferredProviderId={props.providerAuthPreferredProviderId}
workerType={props.providerAuthWorkerType}
providers={props.providers}
providers={props.providerAuthProviders}
connectedProviderIds={props.providerConnectedIds}
authMethods={props.providerAuthMethods}
onSelect={handleProviderAuthSelect}
onSubmitApiKey={handleProviderAuthApiKey}
onConnectCloudProvider={handleCloudProviderConnect}
onSubmitOAuth={handleProviderAuthOAuth}
onRefreshProviders={props.refreshProviders}
onClose={() => props.closeProviderAuthModal()}

View File

@@ -2,6 +2,9 @@ PORT=8790
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
DATABASE_URL=mysql://root:password@127.0.0.1:3306/den
BETTER_AUTH_SECRET=replace-with-32-plus-character-secret
# Required dedicated DB encryption key for encrypted columns. Minimum 32 chars.
# Generate one with: openssl rand -base64 128
DEN_DB_ENCRYPTION_KEY=
BETTER_AUTH_URL=http://localhost:8790
DEN_BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001
LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL=replace-with-loops-template-id

View File

@@ -6,6 +6,7 @@ const EnvSchema = z.object({
DATABASE_HOST: z.string().min(1).optional(),
DATABASE_USERNAME: z.string().min(1).optional(),
DATABASE_PASSWORD: z.string().optional(),
DEN_DB_ENCRYPTION_KEY: z.string().trim().min(32),
DB_MODE: z.enum(["mysql", "planetscale"]).optional(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().min(1),
@@ -145,6 +146,7 @@ const planetscaleCredentials =
export const env = {
databaseUrl: parsed.DATABASE_URL,
dbEncryptionKey: optionalString(parsed.DEN_DB_ENCRYPTION_KEY),
dbMode: parsed.DB_MODE ?? (parsed.DATABASE_URL ? "mysql" : "planetscale"),
planetscale: planetscaleCredentials,
betterAuthSecret: parsed.BETTER_AUTH_SECRET,

View File

@@ -0,0 +1,143 @@
const MODELS_DEV_API_URL = "https://models.dev/api.json"
const MODELS_DEV_CACHE_TTL_MS = 1000 * 60 * 10
type JsonRecord = Record<string, unknown>
export type ModelsDevProviderSummary = {
id: string
name: string
npm: string | null
env: string[]
doc: string | null
api: string | null
modelCount: number
}
export type ModelsDevModel = {
id: string
name: string
config: JsonRecord
}
export type ModelsDevProvider = {
id: string
name: string
npm: string | null
env: string[]
doc: string | null
api: string | null
config: JsonRecord
models: ModelsDevModel[]
}
let modelsDevCache:
| {
expiresAt: number
providers: ModelsDevProvider[]
providersById: Map<string, ModelsDevProvider>
}
| null = null
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null
}
function asStringList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
: []
}
async function loadModelsDevCatalog() {
if (modelsDevCache && modelsDevCache.expiresAt > Date.now()) {
return modelsDevCache
}
const response = await fetch(MODELS_DEV_API_URL, {
headers: {
Accept: "application/json",
"User-Agent": "OpenWork Den API",
},
})
if (!response.ok) {
throw new Error(`models.dev returned ${response.status}`)
}
const payload = await response.json()
if (!isRecord(payload)) {
throw new Error("models.dev returned an invalid payload")
}
const providers = Object.entries(payload)
.map(([providerKey, rawProvider]) => {
if (!isRecord(rawProvider)) {
return null
}
const providerId = asString(rawProvider.id) ?? providerKey
const name = asString(rawProvider.name) ?? providerId
const modelsRecord = isRecord(rawProvider.models) ? rawProvider.models : {}
const { models: _models, ...providerConfig } = rawProvider
const models = Object.entries(modelsRecord)
.map(([modelKey, rawModel]) => {
if (!isRecord(rawModel)) {
return null
}
const modelId = asString(rawModel.id) ?? modelKey
const modelName = asString(rawModel.name) ?? modelId
return {
id: modelId,
name: modelName,
config: rawModel,
} satisfies ModelsDevModel
})
.filter((entry): entry is ModelsDevModel => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name))
return {
id: providerId,
name,
npm: asString(rawProvider.npm),
env: asStringList(rawProvider.env),
doc: asString(rawProvider.doc),
api: asString(rawProvider.api),
config: providerConfig,
models,
} satisfies ModelsDevProvider
})
.filter((entry): entry is ModelsDevProvider => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name))
const nextCache = {
expiresAt: Date.now() + MODELS_DEV_CACHE_TTL_MS,
providers,
providersById: new Map(providers.map((provider) => [provider.id, provider])),
}
modelsDevCache = nextCache
return nextCache
}
export async function listModelsDevProviders(): Promise<ModelsDevProviderSummary[]> {
const catalog = await loadModelsDevCatalog()
return catalog.providers.map((provider) => ({
id: provider.id,
name: provider.name,
npm: provider.npm,
env: provider.env,
doc: provider.doc,
api: provider.api,
modelCount: provider.models.length,
}))
}
export async function getModelsDevProvider(providerId: string): Promise<ModelsDevProvider | null> {
const catalog = await loadModelsDevCatalog()
return catalog.providersById.get(providerId) ?? null
}

View File

@@ -2,6 +2,7 @@ import type { Hono } from "hono"
import type { OrgRouteVariables } from "./shared.js"
import { registerOrgCoreRoutes } from "./core.js"
import { registerOrgInvitationRoutes } from "./invitations.js"
import { registerOrgLlmProviderRoutes } from "./llm-providers.js"
import { registerOrgMemberRoutes } from "./members.js"
import { registerOrgRoleRoutes } from "./roles.js"
import { registerOrgSkillRoutes } from "./skills.js"
@@ -11,6 +12,7 @@ import { registerOrgTemplateRoutes } from "./templates.js"
export function registerOrgRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
registerOrgCoreRoutes(app)
registerOrgInvitationRoutes(app)
registerOrgLlmProviderRoutes(app)
registerOrgMemberRoutes(app)
registerOrgRoleRoutes(app)
registerOrgSkillRoutes(app)

View File

@@ -0,0 +1,922 @@
import { and, desc, eq, inArray, isNotNull, or } from "@openwork-ee/den-db/drizzle"
import {
AuthUserTable,
LlmProviderAccessTable,
LlmProviderModelTable,
LlmProviderTable,
MemberTable,
TeamTable,
} from "@openwork-ee/den-db/schema"
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
import { db } from "../../db.js"
import {
jsonValidator,
paramValidator,
requireUserMiddleware,
resolveMemberTeamsMiddleware,
resolveOrganizationContextMiddleware,
} from "../../middleware/index.js"
import { getModelsDevProvider, listModelsDevProviders } from "../../llm/models-dev.js"
import type { MemberTeamsContext } from "../../middleware/member-teams.js"
import type { OrgRouteVariables } from "./shared.js"
import { idParamSchema, memberHasRole, orgIdParamSchema } from "./shared.js"
type JsonRecord = Record<string, unknown>
type LlmProviderId = typeof LlmProviderTable.$inferSelect.id
type LlmProviderAccessId = typeof LlmProviderAccessTable.$inferSelect.id
type MemberId = typeof MemberTable.$inferSelect.id
type TeamId = typeof TeamTable.$inferSelect.id
type LlmProviderRow = typeof LlmProviderTable.$inferSelect
type RouteFailure = {
status: number
error: string
message?: string
}
const providerCatalogParamsSchema = orgIdParamSchema.extend({
providerId: z.string().trim().min(1).max(255),
})
const orgLlmProviderParamsSchema = orgIdParamSchema.extend(idParamSchema("llmProviderId").shape)
const customModelSchema = z.object({
id: z.string().trim().min(1).max(255),
name: z.string().trim().min(1).max(255),
}).passthrough()
const customProviderSchema = z.object({
id: z.string().trim().min(1).max(255),
name: z.string().trim().min(1).max(255),
npm: z.string().trim().min(1).max(255),
env: z.array(z.string().trim().min(1).max(255)).min(1),
doc: z.string().trim().min(1).max(2048),
api: z.string().trim().min(1).max(2048).optional(),
models: z.array(customModelSchema).min(1),
}).passthrough()
const llmProviderWriteSchema = z.object({
source: z.enum(["models_dev", "custom"]),
providerId: z.string().trim().min(1).max(255).optional(),
modelIds: z.array(z.string().trim().min(1).max(255)).min(1).optional(),
customConfigText: z.string().trim().min(1).optional(),
apiKey: z.string().trim().max(65535).optional(),
memberIds: z.array(z.string().trim().min(1).max(255)).max(500).optional().default([]),
teamIds: z.array(z.string().trim().min(1).max(255)).max(500).optional().default([]),
}).superRefine((value, ctx) => {
if (value.source === "models_dev") {
if (!value.providerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["providerId"],
message: "Select a provider.",
})
}
if (!value.modelIds?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["modelIds"],
message: "Select at least one model.",
})
}
}
if (value.source === "custom" && !value.customConfigText) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customConfigText"],
message: "Paste a custom provider config.",
})
}
})
function createFailure(status: number, error: string, message?: string): RouteFailure {
return { status, error, message }
}
function isRouteFailure(value: unknown): value is RouteFailure {
return typeof value === "object" && value !== null && "status" in value && "error" in value
}
function isOrganizationAdmin(payload: { currentMember: { isOwner: boolean; role: string } }) {
return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")
}
function canManageLlmProvider(
payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } },
provider: LlmProviderRow,
) {
return isOrganizationAdmin(payload) || provider.createdByOrgMembershipId === payload.currentMember.id
}
async function canAccessLlmProvider(input: {
organizationId: typeof LlmProviderTable.$inferSelect.organizationId
llmProviderId: LlmProviderId
currentMemberId: MemberId
memberTeams: Array<{ id: TeamId }>
isAdmin: boolean
}) {
if (input.isAdmin) {
return true
}
const access = await listAccessibleProviderAccess({
organizationId: input.organizationId,
currentMemberId: input.currentMemberId,
memberTeams: input.memberTeams,
})
return access.some((entry) => entry.llmProviderId === input.llmProviderId)
}
function parseLlmProviderId(value: string) {
return normalizeDenTypeId("llmProvider", value)
}
function parseLlmProviderAccessId(value: string) {
return normalizeDenTypeId("llmProviderAccess", value)
}
function parseMemberId(value: string) {
return normalizeDenTypeId("member", value)
}
function parseTeamId(value: string) {
return normalizeDenTypeId("team", value)
}
async function listAccessibleProviderAccess(input: {
organizationId: typeof LlmProviderTable.$inferSelect.organizationId
currentMemberId: MemberId
memberTeams: Array<{ id: TeamId }>
}) {
const teamIds = input.memberTeams.map((team) => team.id)
const accessWhere = teamIds.length > 0
? and(
eq(LlmProviderTable.organizationId, input.organizationId),
or(
eq(LlmProviderAccessTable.orgMembershipId, input.currentMemberId),
inArray(LlmProviderAccessTable.teamId, teamIds),
),
)
: and(
eq(LlmProviderTable.organizationId, input.organizationId),
eq(LlmProviderAccessTable.orgMembershipId, input.currentMemberId),
)
return db
.select({
id: LlmProviderAccessTable.id,
llmProviderId: LlmProviderAccessTable.llmProviderId,
orgMembershipId: LlmProviderAccessTable.orgMembershipId,
teamId: LlmProviderAccessTable.teamId,
createdAt: LlmProviderAccessTable.createdAt,
})
.from(LlmProviderAccessTable)
.innerJoin(LlmProviderTable, eq(LlmProviderAccessTable.llmProviderId, LlmProviderTable.id))
.where(accessWhere)
}
async function resolveMemberIds(input: {
organizationId: typeof LlmProviderTable.$inferSelect.organizationId
values: string[]
}) {
const uniqueValues = [...new Set(input.values)]
if (uniqueValues.length === 0) {
return [] as MemberId[]
}
const memberIds = uniqueValues.map((value) => {
try {
return parseMemberId(value)
} catch {
throw createFailure(404, "member_not_found")
}
})
const rows = await db
.select({ id: MemberTable.id })
.from(MemberTable)
.where(and(eq(MemberTable.organizationId, input.organizationId), inArray(MemberTable.id, memberIds)))
if (rows.length !== memberIds.length) {
throw createFailure(404, "member_not_found")
}
return memberIds
}
async function resolveTeamIds(input: {
organizationId: typeof LlmProviderTable.$inferSelect.organizationId
values: string[]
}) {
const uniqueValues = [...new Set(input.values)]
if (uniqueValues.length === 0) {
return [] as TeamId[]
}
const teamIds = uniqueValues.map((value) => {
try {
return parseTeamId(value)
} catch {
throw createFailure(404, "team_not_found")
}
})
const rows = await db
.select({ id: TeamTable.id })
.from(TeamTable)
.where(and(eq(TeamTable.organizationId, input.organizationId), inArray(TeamTable.id, teamIds)))
if (rows.length !== teamIds.length) {
throw createFailure(404, "team_not_found")
}
return teamIds
}
async function normalizeLlmProviderInput(input: z.infer<typeof llmProviderWriteSchema>) {
if (input.source === "models_dev") {
const provider = await getModelsDevProvider(input.providerId ?? "")
if (!provider) {
throw createFailure(404, "provider_not_found", "The selected provider was not found in models.dev.")
}
const requestedModelIds = [...new Set(input.modelIds ?? [])]
const modelsById = new Map(provider.models.map((model) => [model.id, model]))
const models = requestedModelIds.map((modelId) => {
const model = modelsById.get(modelId)
if (!model) {
throw createFailure(404, "model_not_found", `Model ${modelId} is not available for ${provider.name}.`)
}
return model
})
const apiKey = input.apiKey?.trim() || null
return {
source: input.source,
providerId: provider.id,
name: provider.name,
providerConfig: provider.config,
models: models.map((model) => ({
id: model.id,
name: model.name,
config: model.config,
})),
apiKey,
}
}
let parsed: unknown
try {
parsed = JSON.parse(input.customConfigText ?? "")
} catch {
throw createFailure(400, "invalid_custom_provider_config", "Custom provider config must be valid JSON.")
}
const customProvider = customProviderSchema.safeParse(parsed)
if (!customProvider.success) {
throw createFailure(
400,
"invalid_custom_provider_config",
customProvider.error.issues[0]?.message ?? "Custom provider config is invalid.",
)
}
const { models, ...providerConfig } = customProvider.data
return {
source: input.source,
providerId: customProvider.data.id,
name: customProvider.data.name,
providerConfig: providerConfig as JsonRecord,
models: models.map((model) => ({
id: model.id,
name: model.name,
config: model as JsonRecord,
})),
apiKey: input.apiKey?.trim() || null,
}
}
async function loadLlmProviders(input: {
organizationId: typeof LlmProviderTable.$inferSelect.organizationId
currentMemberId: MemberId
memberTeams: Array<{ id: TeamId }>
isAdmin: boolean
}) {
const accessibleAccess = input.isAdmin
? []
: await listAccessibleProviderAccess({
organizationId: input.organizationId,
currentMemberId: input.currentMemberId,
memberTeams: input.memberTeams,
})
const accessibleProviderIds = [...new Set(accessibleAccess.map((entry) => entry.llmProviderId))]
if (!input.isAdmin && accessibleProviderIds.length === 0) {
return []
}
const providers = await db
.select()
.from(LlmProviderTable)
.where(
input.isAdmin
? eq(LlmProviderTable.organizationId, input.organizationId)
: and(
eq(LlmProviderTable.organizationId, input.organizationId),
inArray(LlmProviderTable.id, accessibleProviderIds),
),
)
.orderBy(desc(LlmProviderTable.updatedAt))
if (providers.length === 0) {
return []
}
const providerIds = providers.map((provider) => provider.id)
const models = await db
.select()
.from(LlmProviderModelTable)
.where(inArray(LlmProviderModelTable.llmProviderId, providerIds))
const memberAccessRows = await db
.select({
access: {
id: LlmProviderAccessTable.id,
llmProviderId: LlmProviderAccessTable.llmProviderId,
createdAt: LlmProviderAccessTable.createdAt,
},
member: {
id: MemberTable.id,
role: MemberTable.role,
},
user: {
id: AuthUserTable.id,
name: AuthUserTable.name,
email: AuthUserTable.email,
image: AuthUserTable.image,
},
})
.from(LlmProviderAccessTable)
.innerJoin(MemberTable, eq(LlmProviderAccessTable.orgMembershipId, MemberTable.id))
.innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id))
.where(and(inArray(LlmProviderAccessTable.llmProviderId, providerIds), isNotNull(LlmProviderAccessTable.orgMembershipId)))
const teamAccessRows = await db
.select({
access: {
id: LlmProviderAccessTable.id,
llmProviderId: LlmProviderAccessTable.llmProviderId,
createdAt: LlmProviderAccessTable.createdAt,
},
team: {
id: TeamTable.id,
name: TeamTable.name,
createdAt: TeamTable.createdAt,
updatedAt: TeamTable.updatedAt,
},
})
.from(LlmProviderAccessTable)
.innerJoin(TeamTable, eq(LlmProviderAccessTable.teamId, TeamTable.id))
.where(and(inArray(LlmProviderAccessTable.llmProviderId, providerIds), isNotNull(LlmProviderAccessTable.teamId)))
const modelsByProviderId = new Map<LlmProviderId, typeof models>()
for (const model of models) {
const existing = modelsByProviderId.get(model.llmProviderId) ?? []
existing.push(model)
modelsByProviderId.set(model.llmProviderId, existing)
}
const memberAccessByProviderId = new Map<LlmProviderId, typeof memberAccessRows>()
for (const row of memberAccessRows) {
const existing = memberAccessByProviderId.get(row.access.llmProviderId) ?? []
existing.push(row)
memberAccessByProviderId.set(row.access.llmProviderId, existing)
}
const teamAccessByProviderId = new Map<LlmProviderId, typeof teamAccessRows>()
for (const row of teamAccessRows) {
const existing = teamAccessByProviderId.get(row.access.llmProviderId) ?? []
existing.push(row)
teamAccessByProviderId.set(row.access.llmProviderId, existing)
}
const accessibleViaByProviderId = new Map<LlmProviderId, { orgMembershipIds: MemberId[]; teamIds: TeamId[] }>()
for (const row of accessibleAccess) {
const existing = accessibleViaByProviderId.get(row.llmProviderId) ?? { orgMembershipIds: [], teamIds: [] }
if (row.orgMembershipId && !existing.orgMembershipIds.includes(row.orgMembershipId)) {
existing.orgMembershipIds.push(row.orgMembershipId)
}
if (row.teamId && !existing.teamIds.includes(row.teamId)) {
existing.teamIds.push(row.teamId)
}
accessibleViaByProviderId.set(row.llmProviderId, existing)
}
return providers.map((provider) => ({
...provider,
hasApiKey: Boolean(provider.apiKey && provider.apiKey.trim().length > 0),
models: (modelsByProviderId.get(provider.id) ?? [])
.map((model) => ({
id: model.modelId,
name: model.name,
config: model.modelConfig,
createdAt: model.createdAt,
}))
.sort((left, right) => left.name.localeCompare(right.name)),
access: {
members: (memberAccessByProviderId.get(provider.id) ?? []).map((row) => ({
id: row.access.id,
orgMembershipId: row.member.id,
role: row.member.role,
user: row.user,
createdAt: row.access.createdAt,
})),
teams: (teamAccessByProviderId.get(provider.id) ?? []).map((row) => ({
id: row.access.id,
teamId: row.team.id,
name: row.team.name,
createdAt: row.team.createdAt,
updatedAt: row.team.updatedAt,
})),
},
accessibleVia: accessibleViaByProviderId.get(provider.id) ?? { orgMembershipIds: [], teamIds: [] },
}))
}
export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVariables & Partial<MemberTeamsContext> }>(app: Hono<T>) {
app.get(
"/v1/orgs/:orgId/llm-provider-catalog",
requireUserMiddleware,
paramValidator(orgIdParamSchema),
resolveOrganizationContextMiddleware,
async (c) => {
try {
const providers = await listModelsDevProviders()
return c.json({ providers })
} catch (error) {
return c.json({
error: "provider_catalog_unavailable",
message: error instanceof Error ? error.message : "Could not load the models.dev catalog.",
}, 502)
}
},
)
app.get(
"/v1/orgs/:orgId/llm-provider-catalog/:providerId",
requireUserMiddleware,
paramValidator(providerCatalogParamsSchema),
resolveOrganizationContextMiddleware,
async (c) => {
const params = c.req.valid("param")
try {
const provider = await getModelsDevProvider(params.providerId)
if (!provider) {
return c.json({ error: "provider_not_found" }, 404)
}
return c.json({
provider: {
id: provider.id,
name: provider.name,
npm: provider.npm,
env: provider.env,
doc: provider.doc,
api: provider.api,
config: provider.config,
models: provider.models,
},
})
} catch (error) {
return c.json({
error: "provider_catalog_unavailable",
message: error instanceof Error ? error.message : "Could not load the provider details.",
}, 502)
}
},
)
app.get(
"/v1/orgs/:orgId/llm-providers",
requireUserMiddleware,
paramValidator(orgIdParamSchema),
resolveOrganizationContextMiddleware,
resolveMemberTeamsMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const memberTeams = c.get("memberTeams") ?? []
const providers = await loadLlmProviders({
organizationId: payload.organization.id,
currentMemberId: payload.currentMember.id,
memberTeams,
isAdmin: isOrganizationAdmin(payload),
})
return c.json({
llmProviders: providers.map((provider) => ({
...provider,
apiKey: undefined,
canManage: canManageLlmProvider(payload, provider),
})),
})
},
)
app.get(
"/v1/orgs/:orgId/llm-providers/:llmProviderId/connect",
requireUserMiddleware,
paramValidator(orgLlmProviderParamsSchema),
resolveOrganizationContextMiddleware,
resolveMemberTeamsMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const memberTeams = c.get("memberTeams") ?? []
const params = c.req.valid("param")
let llmProviderId: LlmProviderId
try {
llmProviderId = parseLlmProviderId(params.llmProviderId)
} catch {
return c.json({ error: "llm_provider_not_found" }, 404)
}
const providerRows = await db
.select()
.from(LlmProviderTable)
.where(and(eq(LlmProviderTable.id, llmProviderId), eq(LlmProviderTable.organizationId, payload.organization.id)))
.limit(1)
const provider = providerRows[0]
if (!provider) {
return c.json({ error: "llm_provider_not_found" }, 404)
}
const accessible = await canAccessLlmProvider({
organizationId: payload.organization.id,
llmProviderId,
currentMemberId: payload.currentMember.id,
memberTeams,
isAdmin: isOrganizationAdmin(payload),
})
if (!accessible) {
return c.json({
error: "forbidden",
message: "You do not have access to this provider.",
}, 403)
}
const models = await db
.select()
.from(LlmProviderModelTable)
.where(eq(LlmProviderModelTable.llmProviderId, llmProviderId))
return c.json({
llmProvider: {
...provider,
models: models
.map((model) => ({
id: model.modelId,
name: model.name,
config: model.modelConfig,
createdAt: model.createdAt,
}))
.sort((left, right) => left.name.localeCompare(right.name)),
},
})
},
)
app.post(
"/v1/orgs/:orgId/llm-providers",
requireUserMiddleware,
paramValidator(orgIdParamSchema),
resolveOrganizationContextMiddleware,
jsonValidator(llmProviderWriteSchema),
async (c) => {
const payload = c.get("organizationContext")
const input = c.req.valid("json")
try {
const normalized = await normalizeLlmProviderInput(input)
const memberIds = await resolveMemberIds({
organizationId: payload.organization.id,
values: input.memberIds,
})
const teamIds = await resolveTeamIds({
organizationId: payload.organization.id,
values: input.teamIds,
})
const llmProviderId = createDenTypeId("llmProvider")
const protectedMemberIds = [...new Set([payload.currentMember.id, ...memberIds])]
const now = new Date()
await db.transaction(async (tx) => {
await tx.insert(LlmProviderTable).values({
id: llmProviderId,
organizationId: payload.organization.id,
createdByOrgMembershipId: payload.currentMember.id,
source: normalized.source,
providerId: normalized.providerId,
name: normalized.name,
providerConfig: normalized.providerConfig,
apiKey: normalized.apiKey,
createdAt: now,
updatedAt: now,
})
if (normalized.models.length > 0) {
await tx.insert(LlmProviderModelTable).values(
normalized.models.map((model) => ({
id: createDenTypeId("llmProviderModel"),
llmProviderId,
modelId: model.id,
name: model.name,
modelConfig: model.config,
createdAt: now,
})),
)
}
const accessRows = [
...protectedMemberIds.map((orgMembershipId) => ({
id: createDenTypeId("llmProviderAccess"),
llmProviderId,
orgMembershipId,
teamId: null,
createdAt: now,
})),
...teamIds.map((teamId) => ({
id: createDenTypeId("llmProviderAccess"),
llmProviderId,
orgMembershipId: null,
teamId,
createdAt: now,
})),
]
if (accessRows.length > 0) {
await tx.insert(LlmProviderAccessTable).values(accessRows)
}
})
return c.json({
llmProvider: {
id: llmProviderId,
organizationId: payload.organization.id,
createdByOrgMembershipId: payload.currentMember.id,
source: normalized.source,
providerId: normalized.providerId,
name: normalized.name,
providerConfig: normalized.providerConfig,
hasApiKey: Boolean(normalized.apiKey),
createdAt: now,
updatedAt: now,
},
}, 201)
} catch (error) {
if (isRouteFailure(error)) {
return c.json(
{ error: error.error, message: error.message },
{ status: error.status as 400 | 404 },
)
}
throw error
}
},
)
app.patch(
"/v1/orgs/:orgId/llm-providers/:llmProviderId",
requireUserMiddleware,
paramValidator(orgLlmProviderParamsSchema),
resolveOrganizationContextMiddleware,
jsonValidator(llmProviderWriteSchema),
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
const input = c.req.valid("json")
let llmProviderId: LlmProviderId
try {
llmProviderId = parseLlmProviderId(params.llmProviderId)
} catch {
return c.json({ error: "llm_provider_not_found" }, 404)
}
const providerRows = await db
.select()
.from(LlmProviderTable)
.where(and(eq(LlmProviderTable.id, llmProviderId), eq(LlmProviderTable.organizationId, payload.organization.id)))
.limit(1)
const provider = providerRows[0]
if (!provider) {
return c.json({ error: "llm_provider_not_found" }, 404)
}
if (!canManageLlmProvider(payload, provider)) {
return c.json({
error: "forbidden",
message: "Only the provider creator or an org admin can update providers.",
}, 403)
}
try {
const normalized = await normalizeLlmProviderInput(input)
const memberIds = await resolveMemberIds({
organizationId: payload.organization.id,
values: input.memberIds,
})
const teamIds = await resolveTeamIds({
organizationId: payload.organization.id,
values: input.teamIds,
})
const protectedMemberIds = [...new Set([provider.createdByOrgMembershipId, ...memberIds])]
const updatedAt = new Date()
await db.transaction(async (tx) => {
await tx
.update(LlmProviderTable)
.set({
source: normalized.source,
providerId: normalized.providerId,
name: normalized.name,
providerConfig: normalized.providerConfig,
apiKey: input.apiKey === undefined ? provider.apiKey : normalized.apiKey,
updatedAt,
})
.where(eq(LlmProviderTable.id, provider.id))
await tx.delete(LlmProviderModelTable).where(eq(LlmProviderModelTable.llmProviderId, provider.id))
await tx.delete(LlmProviderAccessTable).where(eq(LlmProviderAccessTable.llmProviderId, provider.id))
if (normalized.models.length > 0) {
await tx.insert(LlmProviderModelTable).values(
normalized.models.map((model) => ({
id: createDenTypeId("llmProviderModel"),
llmProviderId: provider.id,
modelId: model.id,
name: model.name,
modelConfig: model.config,
createdAt: updatedAt,
})),
)
}
const accessRows = [
...protectedMemberIds.map((orgMembershipId) => ({
id: createDenTypeId("llmProviderAccess"),
llmProviderId: provider.id,
orgMembershipId,
teamId: null,
createdAt: updatedAt,
})),
...teamIds.map((teamId) => ({
id: createDenTypeId("llmProviderAccess"),
llmProviderId: provider.id,
orgMembershipId: null,
teamId,
createdAt: updatedAt,
})),
]
if (accessRows.length > 0) {
await tx.insert(LlmProviderAccessTable).values(accessRows)
}
})
return c.json({
llmProvider: {
...provider,
source: normalized.source,
providerId: normalized.providerId,
name: normalized.name,
providerConfig: normalized.providerConfig,
hasApiKey: input.apiKey === undefined ? Boolean(provider.apiKey) : Boolean(normalized.apiKey),
updatedAt,
},
})
} catch (error) {
if (isRouteFailure(error)) {
return c.json(
{ error: error.error, message: error.message },
{ status: error.status as 400 | 404 },
)
}
throw error
}
},
)
app.delete(
"/v1/orgs/:orgId/llm-providers/:llmProviderId",
requireUserMiddleware,
paramValidator(orgLlmProviderParamsSchema),
resolveOrganizationContextMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let llmProviderId: LlmProviderId
try {
llmProviderId = parseLlmProviderId(params.llmProviderId)
} catch {
return c.json({ error: "llm_provider_not_found" }, 404)
}
const providerRows = await db
.select()
.from(LlmProviderTable)
.where(and(eq(LlmProviderTable.id, llmProviderId), eq(LlmProviderTable.organizationId, payload.organization.id)))
.limit(1)
const provider = providerRows[0]
if (!provider) {
return c.json({ error: "llm_provider_not_found" }, 404)
}
if (!canManageLlmProvider(payload, provider)) {
return c.json({
error: "forbidden",
message: "Only the provider creator or an org admin can delete providers.",
}, 403)
}
await db.transaction(async (tx) => {
await tx.delete(LlmProviderAccessTable).where(eq(LlmProviderAccessTable.llmProviderId, provider.id))
await tx.delete(LlmProviderModelTable).where(eq(LlmProviderModelTable.llmProviderId, provider.id))
await tx.delete(LlmProviderTable).where(eq(LlmProviderTable.id, provider.id))
})
return c.body(null, 204)
},
)
app.delete(
"/v1/orgs/:orgId/llm-providers/:llmProviderId/access/:accessId",
requireUserMiddleware,
paramValidator(orgLlmProviderParamsSchema.extend(idParamSchema("accessId").shape)),
resolveOrganizationContextMiddleware,
async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let llmProviderId: LlmProviderId
let accessId: LlmProviderAccessId
try {
llmProviderId = parseLlmProviderId(params.llmProviderId)
accessId = parseLlmProviderAccessId(params.accessId)
} catch {
return c.json({ error: "not_found" }, 404)
}
const providerRows = await db
.select()
.from(LlmProviderTable)
.where(and(eq(LlmProviderTable.id, llmProviderId), eq(LlmProviderTable.organizationId, payload.organization.id)))
.limit(1)
const provider = providerRows[0]
if (!provider) {
return c.json({ error: "llm_provider_not_found" }, 404)
}
if (!canManageLlmProvider(payload, provider)) {
return c.json({ error: "forbidden", message: "Only the provider creator or an org admin can manage access." }, 403)
}
const accessRows = await db
.select()
.from(LlmProviderAccessTable)
.where(and(eq(LlmProviderAccessTable.id, accessId), eq(LlmProviderAccessTable.llmProviderId, provider.id)))
.limit(1)
const access = accessRows[0]
if (!access) {
return c.json({ error: "llm_provider_access_not_found" }, 404)
}
if (access.orgMembershipId === provider.createdByOrgMembershipId) {
return c.json({
error: "protected_access",
message: "The provider creator always keeps direct access.",
}, 409)
}
await db.delete(LlmProviderAccessTable).where(eq(LlmProviderAccessTable.id, access.id))
return c.body(null, 204)
},
)
}

View File

@@ -255,7 +255,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
<div className="grid gap-3 text-sm text-[var(--dls-text-secondary)]">
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Share setup across your team and org</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Background agents in alpha for selected workflows</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Custom LLM providers for teams, coming soon</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Custom LLM providers with team access controls</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
@@ -266,9 +266,9 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
</p>
</div>
<div className="den-frame-inset rounded-[1.5rem] p-4">
<p className="den-stat-label">Custom LLM providers</p>
<p className="den-stat-label">LLM providers</p>
<p className="mt-3 text-sm text-[var(--dls-text-secondary)]">
Standardize provider access for your team. Coming soon.
Standardize provider access, model selection, and team rollout.
</p>
</div>
</div>

View File

@@ -200,6 +200,22 @@ export function getCustomLlmProvidersRoute(orgSlug: string): string {
return `${getOrgDashboardRoute(orgSlug)}/custom-llm-providers`;
}
export function getLlmProvidersRoute(orgSlug: string): string {
return getCustomLlmProvidersRoute(orgSlug);
}
export function getLlmProviderRoute(orgSlug: string, llmProviderId: string): string {
return `${getLlmProvidersRoute(orgSlug)}/${encodeURIComponent(llmProviderId)}`;
}
export function getEditLlmProviderRoute(orgSlug: string, llmProviderId: string): string {
return `${getLlmProviderRoute(orgSlug, llmProviderId)}/edit`;
}
export function getNewLlmProviderRoute(orgSlug: string): string {
return `${getLlmProvidersRoute(orgSlug)}/new`;
}
export function getBillingRoute(orgSlug: string): string {
return `${getOrgDashboardRoute(orgSlug)}/billing`;
}

View File

@@ -1,36 +0,0 @@
"use client";
import { Cpu } from "lucide-react";
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
const comingSoonItems = [
"Standardize provider access across your team.",
"Keep model choices consistent across shared setups.",
"Control rollout without reconfiguring every teammate by hand.",
];
export function CustomLlmProvidersScreen() {
return (
<DashboardPageTemplate
icon={Cpu}
badgeLabel="Coming soon"
title="Custom LLMs"
description="Standardize provider access for your team."
colors={["#E0FCFF", "#1D7B9A", "#50F7D4", "#518EF0"]}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{comingSoonItems.map((text) => (
<div
key={text}
className="flex flex-col gap-3 rounded-2xl border border-gray-100 bg-white p-6"
>
<span className="inline-block self-start rounded-full border border-gray-100 bg-gray-50 px-2 py-0.5 text-[10px] uppercase tracking-[1px] text-gray-500">
Coming soon
</span>
<p className="text-[13px] leading-[1.6] text-gray-600">{text}</p>
</div>
))}
</div>
</DashboardPageTemplate>
);
}

View File

@@ -69,7 +69,7 @@ export function DashboardOverviewScreen() {
tint: "bg-orange-50 text-orange-500 group-hover:bg-orange-100",
},
{
label: "Custom LLMs",
label: "LLM Providers",
icon: Cpu,
href: getCustomLlmProvidersRoute(orgSlug),
tint: "bg-lime-50 text-lime-600 group-hover:bg-lime-100",

View File

@@ -0,0 +1,430 @@
"use client";
import { useEffect, useState } from "react";
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
export type DenLlmProviderSource = "models_dev" | "custom";
export type DenLlmProviderModel = {
id: string;
name: string;
config: Record<string, unknown>;
createdAt: string | null;
};
export type DenLlmProviderMemberAccess = {
id: string;
orgMembershipId: string;
role: string;
createdAt: string | null;
user: {
id: string;
name: string;
email: string;
image: string | null;
};
};
export type DenLlmProviderTeamAccess = {
id: string;
teamId: string;
name: string;
createdAt: string | null;
updatedAt: string | null;
};
export type DenLlmProvider = {
id: string;
organizationId: string;
createdByOrgMembershipId: string;
source: DenLlmProviderSource;
providerId: string;
name: string;
providerConfig: Record<string, unknown>;
hasApiKey: boolean;
createdAt: string | null;
updatedAt: string | null;
canManage: boolean;
accessibleVia: {
orgMembershipIds: string[];
teamIds: string[];
};
models: DenLlmProviderModel[];
access: {
members: DenLlmProviderMemberAccess[];
teams: DenLlmProviderTeamAccess[];
};
};
export type DenModelsDevProviderSummary = {
id: string;
name: string;
npm: string | null;
env: string[];
doc: string | null;
api: string | null;
modelCount: number;
};
export type DenModelsDevProviderDetail = DenModelsDevProviderSummary & {
config: Record<string, unknown>;
models: Array<{
id: string;
name: string;
config: Record<string, unknown>;
}>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function asIsoString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function asStringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
}
function asJsonRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function asLlmProviderModel(value: unknown): DenLlmProviderModel | null {
if (!isRecord(value)) {
return null;
}
const id = asString(value.id);
const name = asString(value.name);
if (!id || !name) {
return null;
}
return {
id,
name,
config: asJsonRecord(value.config),
createdAt: asIsoString(value.createdAt),
};
}
function asLlmProviderMemberAccess(value: unknown): DenLlmProviderMemberAccess | null {
if (!isRecord(value) || !isRecord(value.user)) {
return null;
}
const id = asString(value.id);
const orgMembershipId = asString(value.orgMembershipId);
const role = asString(value.role);
const userId = asString(value.user.id);
const name = asString(value.user.name);
const email = asString(value.user.email);
if (!id || !orgMembershipId || !role || !userId || !name || !email) {
return null;
}
return {
id,
orgMembershipId,
role,
createdAt: asIsoString(value.createdAt),
user: {
id: userId,
name,
email,
image: asString(value.user.image),
},
};
}
function asLlmProviderTeamAccess(value: unknown): DenLlmProviderTeamAccess | null {
if (!isRecord(value)) {
return null;
}
const id = asString(value.id);
const teamId = asString(value.teamId);
const name = asString(value.name);
if (!id || !teamId || !name) {
return null;
}
return {
id,
teamId,
name,
createdAt: asIsoString(value.createdAt),
updatedAt: asIsoString(value.updatedAt),
};
}
function asLlmProvider(value: unknown): DenLlmProvider | null {
if (!isRecord(value) || !isRecord(value.access) || !isRecord(value.accessibleVia)) {
return null;
}
const id = asString(value.id);
const organizationId = asString(value.organizationId);
const createdByOrgMembershipId = asString(value.createdByOrgMembershipId);
const providerId = asString(value.providerId);
const name = asString(value.name);
const source = value.source === "models_dev" || value.source === "custom" ? value.source : null;
if (!id || !organizationId || !createdByOrgMembershipId || !providerId || !name || !source) {
return null;
}
return {
id,
organizationId,
createdByOrgMembershipId,
source,
providerId,
name,
providerConfig: asJsonRecord(value.providerConfig),
hasApiKey: value.hasApiKey === true,
createdAt: asIsoString(value.createdAt),
updatedAt: asIsoString(value.updatedAt),
canManage: value.canManage === true,
accessibleVia: {
orgMembershipIds: asStringList(value.accessibleVia.orgMembershipIds),
teamIds: asStringList(value.accessibleVia.teamIds),
},
models: Array.isArray(value.models)
? value.models.map(asLlmProviderModel).filter((entry): entry is DenLlmProviderModel => entry !== null)
: [],
access: {
members: Array.isArray(value.access.members)
? value.access.members
.map(asLlmProviderMemberAccess)
.filter((entry): entry is DenLlmProviderMemberAccess => entry !== null)
: [],
teams: Array.isArray(value.access.teams)
? value.access.teams
.map(asLlmProviderTeamAccess)
.filter((entry): entry is DenLlmProviderTeamAccess => entry !== null)
: [],
},
};
}
function asCatalogProviderSummary(value: unknown): DenModelsDevProviderSummary | null {
if (!isRecord(value)) {
return null;
}
const id = asString(value.id);
const name = asString(value.name);
if (!id || !name) {
return null;
}
return {
id,
name,
npm: asString(value.npm),
env: asStringList(value.env),
doc: asString(value.doc),
api: asString(value.api),
modelCount: typeof value.modelCount === "number" ? value.modelCount : 0,
};
}
function asCatalogProviderDetail(value: unknown): DenModelsDevProviderDetail | null {
const summary = asCatalogProviderSummary(value);
if (!summary || !isRecord(value)) {
return null;
}
const models = Array.isArray(value.models)
? value.models
.map((model) => {
if (!isRecord(model)) {
return null;
}
const id = asString(model.id);
const name = asString(model.name);
if (!id || !name) {
return null;
}
return {
id,
name,
config: asJsonRecord(model.config),
};
})
.filter((entry): entry is DenModelsDevProviderDetail["models"][number] => entry !== null)
: [];
return {
...summary,
config: asJsonRecord(value.config),
models,
};
}
export function getProviderEnvNames(config: Record<string, unknown>): string[] {
return asStringList(config.env);
}
export function getProviderDocUrl(config: Record<string, unknown>): string | null {
return asString(config.doc);
}
export function getProviderNpmPackage(config: Record<string, unknown>): string | null {
return asString(config.npm);
}
export function getProviderApiBase(config: Record<string, unknown>): string | null {
return asString(config.api);
}
export function formatProviderTimestamp(value: string | null) {
if (!value) {
return "Recently updated";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "Recently updated";
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
}
export function buildCustomProviderTemplate() {
return JSON.stringify(
{
id: "custom-provider",
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
env: ["CUSTOM_PROVIDER_API_KEY"],
doc: "https://example.com/docs/models",
api: "https://api.example.com/v1",
models: [
{
id: "custom-provider/example-model",
name: "Example Model",
attachment: false,
reasoning: false,
tool_call: true,
structured_output: true,
temperature: true,
release_date: "2026-01-01",
last_updated: "2026-01-01",
open_weights: false,
limit: {
context: 128000,
input: 128000,
output: 8192,
},
modalities: {
input: ["text"],
output: ["text"],
},
},
],
},
null,
2,
);
}
export function buildEditableCustomProviderText(provider: DenLlmProvider) {
return JSON.stringify(
{
...provider.providerConfig,
models: provider.models.map((model) => model.config),
},
null,
2,
);
}
export async function requestLlmProviderCatalog(orgId: string) {
const { response, payload } = await requestJson(`/v1/orgs/${encodeURIComponent(orgId)}/llm-provider-catalog`, { method: "GET" }, 20000);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load the provider catalog (${response.status}).`));
}
return isRecord(payload) && Array.isArray(payload.providers)
? payload.providers.map(asCatalogProviderSummary).filter((entry): entry is DenModelsDevProviderSummary => entry !== null)
: [];
}
export async function requestLlmProviderCatalogDetail(orgId: string, providerId: string) {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgId)}/llm-provider-catalog/${encodeURIComponent(providerId)}`,
{ method: "GET" },
20000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load provider details (${response.status}).`));
}
if (!isRecord(payload) || !payload.provider) {
throw new Error("Provider details were missing from the response.");
}
const detail = asCatalogProviderDetail(payload.provider);
if (!detail) {
throw new Error("Provider details could not be parsed.");
}
return detail;
}
export function useOrgLlmProviders(orgId: string | null) {
const [llmProviders, setLlmProviders] = useState<DenLlmProvider[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function loadProviders() {
if (!orgId) {
setLlmProviders([]);
setError("Organization not found.");
return;
}
setBusy(true);
setError(null);
try {
const { response, payload } = await requestJson(`/v1/orgs/${encodeURIComponent(orgId)}/llm-providers`, { method: "GET" }, 15000);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load providers (${response.status}).`));
}
const nextProviders = isRecord(payload) && Array.isArray(payload.llmProviders)
? payload.llmProviders.map(asLlmProvider).filter((entry): entry is DenLlmProvider => entry !== null)
: [];
setLlmProviders(nextProviders);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load the provider library.");
} finally {
setBusy(false);
}
}
useEffect(() => {
void loadProviders();
}, [orgId]);
return {
llmProviders,
busy,
error,
reloadProviders: loadProviders,
};
}

View File

@@ -0,0 +1,291 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, ExternalLink, KeyRound, Trash2, Users } from "lucide-react";
import { DenButton } from "../../../../_components/ui/button";
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
import {
getEditLlmProviderRoute,
getLlmProvidersRoute,
} from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import {
formatProviderTimestamp,
getProviderApiBase,
getProviderDocUrl,
getProviderEnvNames,
getProviderNpmPackage,
useOrgLlmProviders,
} from "./llm-provider-data";
function formatCountLabel(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`;
}
function getLimitLabel(config: Record<string, unknown>) {
const limit = typeof config.limit === "object" && config.limit !== null ? config.limit as Record<string, unknown> : null;
const context = typeof limit?.context === "number" ? limit.context : null;
return context ? `${context.toLocaleString()} ctx` : null;
}
export function LlmProviderDetailScreen({ llmProviderId }: { llmProviderId: string }) {
const router = useRouter();
const { orgId, orgSlug } = useOrgDashboard();
const { llmProviders, busy, error, reloadProviders } = useOrgLlmProviders(orgId);
const [deleteBusy, setDeleteBusy] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const provider = useMemo(
() => llmProviders.find((entry) => entry.id === llmProviderId) ?? null,
[llmProviderId, llmProviders],
);
async function deleteProvider() {
if (!orgId || !provider) {
return;
}
if (!window.confirm(`Delete ${provider.name}? This will remove its saved model list and access rules.`)) {
return;
}
setDeleteBusy(true);
setDeleteError(null);
try {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgId)}/llm-providers/${encodeURIComponent(provider.id)}`,
{ method: "DELETE" },
12000,
);
if (response.status !== 204 && !response.ok) {
throw new Error(getErrorMessage(payload, `Failed to delete provider (${response.status}).`));
}
await reloadProviders();
router.push(getLlmProvidersRoute(orgSlug));
router.refresh();
} catch (nextError) {
setDeleteError(nextError instanceof Error ? nextError.message : "Could not delete the provider.");
} finally {
setDeleteBusy(false);
}
}
if (busy && !provider) {
return (
<div className="mx-auto max-w-[1180px] px-6 py-8 md:px-8">
<div className="rounded-[28px] border border-gray-200 bg-white px-6 py-10 text-[15px] text-gray-500">
Loading provider details...
</div>
</div>
);
}
if (!provider) {
return (
<div className="mx-auto max-w-[1180px] px-6 py-8 md:px-8">
<div className="rounded-[28px] border border-red-200 bg-red-50 px-6 py-4 text-[15px] text-red-700">
{error ?? "That provider could not be found."}
</div>
</div>
);
}
const envNames = getProviderEnvNames(provider.providerConfig);
const npmPackage = getProviderNpmPackage(provider.providerConfig);
const apiBase = getProviderApiBase(provider.providerConfig);
const docUrl = getProviderDocUrl(provider.providerConfig);
return (
<div className="mx-auto max-w-[1180px] px-6 py-8 md:px-8">
<div className="mb-8 flex flex-col gap-3">
<p className="text-[12px] font-semibold uppercase tracking-[0.18em] text-gray-400">LLM provider</p>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<h1 className="text-[34px] font-semibold tracking-[-0.07em] text-gray-950">{provider.name}</h1>
<p className="mt-3 max-w-[720px] text-[16px] leading-8 text-gray-500">
Control which models this provider exposes, keep the credential in one place, and share access with the right people or teams.
</p>
</div>
<div className="flex flex-wrap gap-3 text-[13px] font-medium text-gray-600">
<span className="rounded-full border border-gray-200 bg-white px-4 py-2">{provider.source === "custom" ? "Custom" : "Catalog"}</span>
<span className="rounded-full border border-gray-200 bg-white px-4 py-2">{formatCountLabel(provider.models.length, "model", "models")}</span>
<span className="rounded-full border border-gray-200 bg-white px-4 py-2">{provider.hasApiKey ? "Credential saved" : "Credential missing"}</span>
</div>
</div>
</div>
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<Link
href={getLlmProvidersRoute(orgSlug)}
className="inline-flex items-center gap-2 text-[15px] font-medium text-gray-500 transition hover:text-gray-900"
>
<ArrowLeft className="h-5 w-5" />
Back to providers
</Link>
<div className="flex flex-wrap gap-3">
{provider.canManage ? (
<>
<Link href={getEditLlmProviderRoute(orgSlug, provider.id)}>
<DenButton variant="secondary">Edit Provider</DenButton>
</Link>
<DenButton variant="destructive" loading={deleteBusy} onClick={() => void deleteProvider()}>
<Trash2 className="h-4 w-4" />
Delete
</DenButton>
</>
) : null}
</div>
</div>
{deleteError ? (
<div className="mb-6 rounded-[28px] border border-red-200 bg-red-50 px-6 py-4 text-[14px] text-red-700">
{deleteError}
</div>
) : null}
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Provider configuration</h2>
<p className="mt-2 text-[15px] text-gray-500">The core provider metadata and credential state stored for this workspace.</p>
</div>
<div className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-[13px] font-medium ${provider.hasApiKey ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"}`}>
<KeyRound className="h-4 w-4" />
{provider.hasApiKey ? "Credential saved" : "Credential missing"}
</div>
</div>
<div className="mt-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-[24px] bg-gray-50 p-5">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em] text-gray-400">Provider id</p>
<p className="mt-3 text-[16px] font-medium text-gray-900">{provider.providerId}</p>
</div>
<div className="rounded-[24px] bg-gray-50 p-5">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em] text-gray-400">NPM package</p>
<p className="mt-3 text-[16px] font-medium text-gray-900">{npmPackage ?? "Not set"}</p>
</div>
<div className="rounded-[24px] bg-gray-50 p-5">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em] text-gray-400">API base</p>
<p className="mt-3 break-all text-[16px] font-medium text-gray-900">{apiBase ?? "Not set"}</p>
</div>
<div className="rounded-[24px] bg-gray-50 p-5">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em] text-gray-400">Updated</p>
<p className="mt-3 text-[16px] font-medium text-gray-900">{formatProviderTimestamp(provider.updatedAt)}</p>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-2">
{envNames.map((envName) => (
<span key={envName} className="rounded-full bg-gray-100 px-3 py-1 text-[12px] font-medium text-gray-600">
{envName}
</span>
))}
{docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-[12px] font-medium text-gray-600 transition hover:bg-gray-200"
>
Docs
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</div>
</section>
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Selected models</h2>
<p className="mt-2 text-[15px] text-gray-500">The exact models this provider configuration exposes today.</p>
</div>
<div className="rounded-full bg-gray-100 px-4 py-2 text-[13px] font-medium text-gray-600">
{formatCountLabel(provider.models.length, "model", "models")}
</div>
</div>
<div className="mt-8 grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{provider.models.map((model) => {
const limitLabel = getLimitLabel(model.config);
return (
<div key={model.id} className="rounded-[24px] border border-gray-200 bg-gray-50 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[17px] font-semibold tracking-[-0.03em] text-gray-950">{model.name}</p>
<p className="mt-1 text-[13px] text-gray-500">{model.id}</p>
</div>
{limitLabel ? (
<span className="rounded-full bg-white px-3 py-1 text-[12px] font-medium text-gray-600">
{limitLabel}
</span>
) : null}
</div>
</div>
);
})}
</div>
</section>
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Access</h2>
<p className="mt-2 text-[15px] text-gray-500">People and teams who can use this provider.</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-gray-100 px-4 py-2 text-[13px] font-medium text-gray-600">
<Users className="h-4 w-4" />
{provider.access.members.length + provider.access.teams.length} grants
</div>
</div>
<div className="mt-8 grid gap-6 xl:grid-cols-2">
<div className="rounded-[24px] bg-gray-50 p-5">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em] text-gray-400">People</p>
<div className="mt-4 grid gap-3">
{provider.access.members.length === 0 ? (
<p className="text-[14px] text-gray-500">No direct people access yet.</p>
) : provider.access.members.map((member) => (
<div key={member.id} className="rounded-[18px] border border-gray-200 bg-white px-4 py-3">
<p className="text-[15px] font-medium text-gray-900">{member.user.name}</p>
<p className="mt-1 text-[13px] text-gray-500">{member.user.email}</p>
</div>
))}
</div>
</div>
<div className="rounded-[24px] bg-gray-50 p-5">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em] text-gray-400">Teams</p>
<div className="mt-4 grid gap-3">
{provider.access.teams.length === 0 ? (
<p className="text-[14px] text-gray-500">No team access yet.</p>
) : provider.access.teams.map((team) => (
<div key={team.id} className="rounded-[18px] border border-gray-200 bg-white px-4 py-3">
<p className="text-[15px] font-medium text-gray-900">{team.name}</p>
<p className="mt-1 text-[13px] text-gray-500">Updated {formatProviderTimestamp(team.updatedAt)}</p>
</div>
))}
</div>
</div>
</div>
</section>
{provider.source === "custom" ? (
<section className="rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Custom provider payload</h2>
<p className="mt-2 text-[15px] text-gray-500">The raw provider config saved for this custom source.</p>
<pre className="mt-6 overflow-x-auto rounded-[24px] bg-[#0f172a] p-5 text-[13px] leading-6 text-slate-100">
{JSON.stringify(provider.providerConfig, null, 2)}
</pre>
</section>
) : null}
</div>
);
}

View File

@@ -0,0 +1,514 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, CheckCircle2, Circle, Cpu, Search } from "lucide-react";
import { DenButton } from "../../../../_components/ui/button";
import { DenInput } from "../../../../_components/ui/input";
import { UnderlineTabs } from "../../../../_components/ui/tabs";
import { DenTextarea } from "../../../../_components/ui/textarea";
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
import {
getLlmProviderRoute,
getLlmProvidersRoute,
} from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import {
buildCustomProviderTemplate,
buildEditableCustomProviderText,
getProviderApiBase,
getProviderDocUrl,
getProviderEnvNames,
getProviderNpmPackage,
requestLlmProviderCatalog,
requestLlmProviderCatalogDetail,
useOrgLlmProviders,
type DenLlmProvider,
type DenModelsDevProviderDetail,
type DenModelsDevProviderSummary,
type DenLlmProviderSource,
} from "./llm-provider-data";
const SOURCE_TABS = [
{ value: "models_dev" as const, label: "Catalog provider", icon: Cpu },
{ value: "custom" as const, label: "Custom provider", icon: Cpu },
];
function getLockMemberId(provider: DenLlmProvider | null, currentMemberId: string | null) {
return provider?.createdByOrgMembershipId ?? currentMemberId;
}
export function LlmProviderEditorScreen({ llmProviderId }: { llmProviderId?: string }) {
const router = useRouter();
const { orgId, orgSlug, orgContext } = useOrgDashboard();
const { llmProviders, busy, error, reloadProviders } = useOrgLlmProviders(orgId);
const provider = useMemo(
() => (llmProviderId ? llmProviders.find((entry) => entry.id === llmProviderId) ?? null : null),
[llmProviderId, llmProviders],
);
const [source, setSource] = useState<DenLlmProviderSource>("models_dev");
const [catalogProviders, setCatalogProviders] = useState<DenModelsDevProviderSummary[]>([]);
const [catalogBusy, setCatalogBusy] = useState(false);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState("");
const [catalogDetail, setCatalogDetail] = useState<DenModelsDevProviderDetail | null>(null);
const [detailBusy, setDetailBusy] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
const [modelQuery, setModelQuery] = useState("");
const [customConfigText, setCustomConfigText] = useState(buildCustomProviderTemplate());
const [apiKey, setApiKey] = useState("");
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>([]);
const [saveBusy, setSaveBusy] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
if (!orgId) {
setCatalogProviders([]);
return;
}
let canceled = false;
setCatalogBusy(true);
setCatalogError(null);
void requestLlmProviderCatalog(orgId)
.then((providers) => {
if (!canceled) {
setCatalogProviders(providers);
}
})
.catch((loadError) => {
if (!canceled) {
setCatalogError(loadError instanceof Error ? loadError.message : "Failed to load the provider catalog.");
}
})
.finally(() => {
if (!canceled) {
setCatalogBusy(false);
}
});
return () => {
canceled = true;
};
}, [orgId]);
useEffect(() => {
if (provider) {
setSource(provider.source);
setSelectedProviderId(provider.providerId);
setSelectedModelIds(provider.models.map((entry) => entry.id));
setSelectedMemberIds(provider.access.members.map((entry) => entry.orgMembershipId));
setSelectedTeamIds(provider.access.teams.map((entry) => entry.teamId));
setCustomConfigText(provider.source === "custom" ? buildEditableCustomProviderText(provider) : buildCustomProviderTemplate());
setApiKey("");
return;
}
setSource("models_dev");
setSelectedProviderId("");
setSelectedModelIds([]);
setSelectedMemberIds(orgContext?.currentMember.id ? [orgContext.currentMember.id] : []);
setSelectedTeamIds([]);
setCustomConfigText(buildCustomProviderTemplate());
setApiKey("");
}, [orgContext?.currentMember.id, provider]);
useEffect(() => {
if (source !== "models_dev" || !orgId || !selectedProviderId) {
setCatalogDetail(null);
setDetailError(null);
setDetailBusy(false);
return;
}
let canceled = false;
setDetailBusy(true);
setDetailError(null);
void requestLlmProviderCatalogDetail(orgId, selectedProviderId)
.then((detail) => {
if (!canceled) {
setCatalogDetail(detail);
setSelectedModelIds((current) => current.filter((entry) => detail.models.some((model) => model.id === entry)));
}
})
.catch((loadError) => {
if (!canceled) {
setCatalogDetail(null);
setDetailError(loadError instanceof Error ? loadError.message : "Failed to load provider details.");
}
})
.finally(() => {
if (!canceled) {
setDetailBusy(false);
}
});
return () => {
canceled = true;
};
}, [orgId, selectedProviderId, source]);
const currentMemberId = orgContext?.currentMember.id ?? null;
const lockedMemberId = getLockMemberId(provider, currentMemberId);
const filteredModels = useMemo(() => {
const models = catalogDetail?.models ?? [];
const normalizedQuery = modelQuery.trim().toLowerCase();
if (!normalizedQuery) {
return models;
}
return models.filter((model) => model.name.toLowerCase().includes(normalizedQuery) || model.id.toLowerCase().includes(normalizedQuery));
}, [catalogDetail?.models, modelQuery]);
async function saveProvider() {
if (!orgId) {
setSaveError("Organization not found.");
return;
}
if (source === "models_dev") {
if (!selectedProviderId) {
setSaveError("Select a provider.");
return;
}
if (!selectedModelIds.length) {
setSaveError("Select at least one model.");
return;
}
}
if (source === "custom" && !customConfigText.trim()) {
setSaveError("Paste a custom provider config.");
return;
}
setSaveBusy(true);
setSaveError(null);
try {
const body: Record<string, unknown> = {
source,
memberIds: [...new Set(selectedMemberIds)],
teamIds: [...new Set(selectedTeamIds)],
};
if (source === "models_dev") {
body.providerId = selectedProviderId;
body.modelIds = selectedModelIds;
} else {
body.customConfigText = customConfigText;
}
if (apiKey.trim() || !provider) {
body.apiKey = apiKey.trim();
}
const path = provider
? `/v1/orgs/${encodeURIComponent(orgId)}/llm-providers/${encodeURIComponent(provider.id)}`
: `/v1/orgs/${encodeURIComponent(orgId)}/llm-providers`;
const method = provider ? "PATCH" : "POST";
const { response, payload } = await requestJson(
path,
{
method,
body: JSON.stringify(body),
},
20000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to save provider (${response.status}).`));
}
const nextProvider = payload && typeof payload === "object" && payload && "llmProvider" in payload && payload.llmProvider && typeof payload.llmProvider === "object"
? payload.llmProvider as { id?: unknown }
: null;
const nextProviderId = typeof nextProvider?.id === "string" ? nextProvider.id : provider?.id ?? null;
if (!nextProviderId) {
throw new Error("The provider was saved, but no provider id was returned.");
}
await reloadProviders();
router.push(getLlmProviderRoute(orgSlug, nextProviderId));
router.refresh();
} catch (nextError) {
setSaveError(nextError instanceof Error ? nextError.message : "Could not save the provider.");
} finally {
setSaveBusy(false);
}
}
if (busy && llmProviderId && !provider) {
return (
<div className="mx-auto max-w-[1180px] px-6 py-8 md:px-8">
<div className="rounded-[28px] border border-gray-200 bg-white px-6 py-10 text-[15px] text-gray-500">
Loading provider details...
</div>
</div>
);
}
if (llmProviderId && !provider) {
return (
<div className="mx-auto max-w-[1180px] px-6 py-8 md:px-8">
<div className="rounded-[28px] border border-red-200 bg-red-50 px-6 py-4 text-[15px] text-red-700">
{error ?? "That provider could not be found."}
</div>
</div>
);
}
const providerDoc = catalogDetail ? getProviderDocUrl(catalogDetail.config) : null;
const providerNpm = catalogDetail ? getProviderNpmPackage(catalogDetail.config) : null;
const providerApiBase = catalogDetail ? getProviderApiBase(catalogDetail.config) : null;
const providerEnv = catalogDetail ? getProviderEnvNames(catalogDetail.config) : [];
return (
<div className="mx-auto max-w-[1180px] px-6 py-8 md:px-8">
<div className="mb-8 flex flex-col gap-3">
<p className="text-[12px] font-semibold uppercase tracking-[0.18em] text-gray-400">
{provider ? "Edit provider" : "Add provider"}
</p>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<h1 className="text-[34px] font-semibold tracking-[-0.07em] text-gray-950">
{provider ? provider.name : "Add a new LLM provider"}
</h1>
<p className="mt-3 max-w-[720px] text-[16px] leading-8 text-gray-500">
Pick a models.dev provider or paste a custom config, then decide which models and teammates can use it.
</p>
</div>
<div className="flex flex-wrap gap-3 text-[13px] font-medium text-gray-600">
<span className="rounded-full border border-gray-200 bg-white px-4 py-2">
{selectedModelIds.length} {selectedModelIds.length === 1 ? "model selected" : "models selected"}
</span>
<span className="rounded-full border border-gray-200 bg-white px-4 py-2">
{selectedMemberIds.length} people · {selectedTeamIds.length} teams
</span>
</div>
</div>
</div>
<div className="mb-8 flex items-center justify-between gap-4">
<Link
href={provider ? getLlmProviderRoute(orgSlug, provider.id) : getLlmProvidersRoute(orgSlug)}
className="inline-flex items-center gap-2 text-[15px] font-medium text-gray-500 transition hover:text-gray-900"
>
<ArrowLeft className="h-5 w-5" />
Back
</Link>
<DenButton loading={saveBusy} onClick={() => void saveProvider()}>
{provider ? "Save Provider" : "Create Provider"}
</DenButton>
</div>
{saveError ? (
<div className="mb-6 rounded-[28px] border border-red-200 bg-red-50 px-6 py-4 text-[14px] text-red-700">
{saveError}
</div>
) : null}
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<h2 className="mb-6 text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Provider type</h2>
<UnderlineTabs tabs={SOURCE_TABS} activeTab={source} onChange={setSource} />
{source === "models_dev" ? (
<div className="mt-8 grid gap-6">
<label className="grid gap-3">
<span className="text-[14px] font-medium text-gray-700">Provider</span>
<select
value={selectedProviderId}
onChange={(event) => setSelectedProviderId(event.target.value)}
className="h-12 rounded-2xl border border-gray-200 bg-white px-4 text-[14px] text-gray-900 outline-none transition focus:border-gray-400"
>
<option value="">Select a provider...</option>
{catalogProviders.map((catalogProvider) => (
<option key={catalogProvider.id} value={catalogProvider.id}>
{catalogProvider.name} ({catalogProvider.modelCount})
</option>
))}
</select>
</label>
{catalogBusy ? <p className="text-[14px] text-gray-500">Loading provider catalog...</p> : null}
{catalogError ? <p className="text-[14px] text-red-600">{catalogError}</p> : null}
{detailBusy ? <p className="text-[14px] text-gray-500">Loading provider details...</p> : null}
{detailError ? <p className="text-[14px] text-red-600">{detailError}</p> : null}
{catalogDetail ? (
<div className="rounded-[28px] bg-gray-50 p-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-gray-400">NPM package</p>
<p className="mt-2 text-[15px] font-medium text-gray-900">{providerNpm ?? "Not set"}</p>
</div>
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-gray-400">API base</p>
<p className="mt-2 break-all text-[15px] font-medium text-gray-900">{providerApiBase ?? "Not set"}</p>
</div>
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-gray-400">Env keys</p>
<p className="mt-2 text-[15px] font-medium text-gray-900">{providerEnv.join(", ") || "None listed"}</p>
</div>
<div>
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-gray-400">Docs</p>
<p className="mt-2 text-[15px] font-medium text-gray-900">{providerDoc ?? "Not set"}</p>
</div>
</div>
</div>
) : null}
</div>
) : (
<div className="mt-8 grid gap-3">
<span className="text-[14px] font-medium text-gray-700">Custom provider JSON</span>
<DenTextarea
value={customConfigText}
onChange={(event) => setCustomConfigText(event.target.value)}
rows={18}
/>
<p className="text-[13px] text-gray-500">
Use the models.dev-style schema and include a <code className="rounded bg-gray-100 px-1 py-0.5">models</code> array.
</p>
</div>
)}
</section>
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Credential</h2>
<p className="mt-2 text-[15px] text-gray-500">
Save the provider credential in a dedicated text column for now. Leave this blank on edit to keep the existing saved value.
</p>
</div>
{provider?.hasApiKey ? (
<span className="rounded-full bg-emerald-50 px-4 py-2 text-[13px] font-medium text-emerald-700">Existing credential saved</span>
) : null}
</div>
<label className="grid gap-3">
<span className="text-[14px] font-medium text-gray-700">API key / credential</span>
<DenInput
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={provider?.hasApiKey ? "Leave blank to keep current credential" : "Paste the provider credential"}
/>
</label>
</section>
{source === "models_dev" ? (
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Models</h2>
<p className="mt-2 text-[15px] text-gray-500">Pick the exact models this provider should expose.</p>
</div>
<DenInput
type="search"
icon={Search}
value={modelQuery}
onChange={(event) => setModelQuery(event.target.value)}
placeholder="Search models..."
/>
</div>
{catalogDetail ? (
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{filteredModels.map((model) => {
const selected = selectedModelIds.includes(model.id);
return (
<button
key={model.id}
type="button"
onClick={() => setSelectedModelIds((current) => current.includes(model.id) ? current.filter((entry) => entry !== model.id) : [...current, model.id])}
className={`flex items-start gap-4 rounded-[24px] border px-5 py-5 text-left transition ${selected ? "border-[#0f172a] bg-[#f8fafc]" : "border-gray-200 bg-white hover:border-gray-300"}`}
>
{selected ? <CheckCircle2 className="mt-0.5 h-7 w-7 shrink-0 text-[#0f172a]" /> : <Circle className="mt-0.5 h-7 w-7 shrink-0 text-gray-300" />}
<div className="min-w-0">
<p className="text-[18px] font-semibold tracking-[-0.03em] text-gray-950">{model.name}</p>
<p className="mt-2 text-[13px] text-gray-500">{model.id}</p>
</div>
</button>
);
})}
</div>
) : (
<div className="rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
Select a provider to browse its models.
</div>
)}
</section>
) : null}
<section className="mb-8 rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">People access</h2>
<p className="mt-2 text-[15px] text-gray-500">Grant direct access to specific members. The provider creator always keeps access.</p>
<div className="mt-8 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{orgContext?.members.map((member) => {
const selected = selectedMemberIds.includes(member.id);
const locked = lockedMemberId === member.id;
return (
<button
key={member.id}
type="button"
disabled={locked}
onClick={() => setSelectedMemberIds((current) => current.includes(member.id) ? current.filter((entry) => entry !== member.id) : [...current, member.id])}
className={`flex min-h-[88px] items-center gap-4 rounded-[24px] border px-5 py-4 text-left transition ${selected ? "border-[#0f172a] bg-[#0f172a] text-white" : "border-gray-200 bg-white text-gray-700 hover:border-gray-300"} ${locked ? "cursor-default" : "cursor-pointer"}`}
>
{selected ? <CheckCircle2 className="h-7 w-7 shrink-0" /> : <Circle className="h-7 w-7 shrink-0 text-gray-300" />}
<div>
<p className="text-[16px] font-medium tracking-[-0.03em]">{member.user.name}</p>
<p className={`mt-1 text-[13px] ${selected ? "text-white/70" : "text-gray-400"}`}>{member.user.email}</p>
{locked ? <p className={`mt-1 text-[12px] ${selected ? "text-white/60" : "text-gray-400"}`}>Creator access is locked</p> : null}
</div>
</button>
);
}) ?? null}
</div>
</section>
<section className="rounded-[36px] border border-gray-200 bg-white p-8 shadow-[0_18px_48px_-34px_rgba(15,23,42,0.24)]">
<h2 className="text-[24px] font-semibold tracking-[-0.05em] text-gray-950">Team access</h2>
<p className="mt-2 text-[15px] text-gray-500">Grant access to entire teams in one step.</p>
{orgContext?.teams.length ? (
<div className="mt-8 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{orgContext.teams.map((team) => {
const selected = selectedTeamIds.includes(team.id);
return (
<button
key={team.id}
type="button"
onClick={() => setSelectedTeamIds((current) => current.includes(team.id) ? current.filter((entry) => entry !== team.id) : [...current, team.id])}
className={`flex min-h-[88px] items-center gap-4 rounded-[24px] border px-5 py-4 text-left transition ${selected ? "border-[#0f172a] bg-[#0f172a] text-white" : "border-gray-200 bg-white text-gray-700 hover:border-gray-300"}`}
>
{selected ? <CheckCircle2 className="h-7 w-7 shrink-0" /> : <Circle className="h-7 w-7 shrink-0 text-gray-300" />}
<div>
<p className="text-[16px] font-medium tracking-[-0.03em]">{team.name}</p>
<p className={`mt-1 text-[13px] ${selected ? "text-white/70" : "text-gray-400"}`}>
{team.memberIds.length} {team.memberIds.length === 1 ? "member" : "members"}
</p>
</div>
</button>
);
})}
</div>
) : (
<div className="mt-8 rounded-[24px] border border-dashed border-gray-200 bg-gray-50 px-5 py-6 text-[15px] text-gray-500">
Create teams from the Members page before assigning team access.
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Cpu, KeyRound, Plus, Search } from "lucide-react";
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
import { buttonVariants } from "../../../../_components/ui/button";
import { DenInput } from "../../../../_components/ui/input";
import {
getLlmProviderRoute,
getNewLlmProviderRoute,
} from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import {
formatProviderTimestamp,
getProviderDocUrl,
getProviderEnvNames,
useOrgLlmProviders,
} from "./llm-provider-data";
function getProviderSourceLabel(source: "models_dev" | "custom") {
return source === "custom" ? "Custom" : "Catalog";
}
export function LlmProvidersScreen() {
const { orgId, orgSlug } = useOrgDashboard();
const { llmProviders, busy, error } = useOrgLlmProviders(orgId);
const [query, setQuery] = useState("");
const filteredProviders = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return llmProviders;
}
return llmProviders.filter((provider) => {
const env = getProviderEnvNames(provider.providerConfig).join(" ").toLowerCase();
const doc = (getProviderDocUrl(provider.providerConfig) ?? "").toLowerCase();
return (
provider.name.toLowerCase().includes(normalizedQuery) ||
provider.providerId.toLowerCase().includes(normalizedQuery) ||
provider.models.some((model) => model.name.toLowerCase().includes(normalizedQuery)) ||
env.includes(normalizedQuery) ||
doc.includes(normalizedQuery)
);
});
}, [llmProviders, query]);
return (
<DashboardPageTemplate
icon={Cpu}
badgeLabel="New"
title="LLM Providers"
description="Configure catalog-backed or custom providers, choose the exact models each one exposes, and grant access to the right people and teams."
colors={["#F3FFF9", "#0F766E", "#34D399", "#7DD3FC"]}
>
<div className="mb-8 flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<DenInput
type="search"
icon={Search}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search providers, models, or env keys..."
/>
<Link href={getNewLlmProviderRoute(orgSlug)} className={buttonVariants({ variant: "primary" })}>
<Plus className="h-4 w-4" aria-hidden="true" />
Add Provider
</Link>
</div>
{error ? (
<div className="mb-6 rounded-[24px] border border-red-200 bg-red-50 px-5 py-4 text-[14px] text-red-700">
{error}
</div>
) : null}
{busy ? (
<div className="rounded-[28px] border border-gray-200 bg-white px-6 py-10 text-[15px] text-gray-500">
Loading your provider library...
</div>
) : filteredProviders.length === 0 ? (
<div className="rounded-[32px] border border-dashed border-gray-200 bg-white px-6 py-12 text-center">
<p className="text-[16px] font-medium tracking-[-0.03em] text-gray-900">
{llmProviders.length === 0 ? "No providers configured yet." : "No providers match that search yet."}
</p>
<p className="mx-auto mt-3 max-w-[560px] text-[15px] leading-8 text-gray-500">
{llmProviders.length === 0
? "Start with a models.dev provider, select the models you want to expose, add the credential, and then grant access to the right people or teams."
: "Try a broader search term, or create a new provider if this org needs a different stack."}
</p>
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{filteredProviders.map((provider) => {
const envNames = getProviderEnvNames(provider.providerConfig);
const memberAccessCount = provider.access.members.length;
const teamAccessCount = provider.access.teams.length;
return (
<Link
key={provider.id}
href={getLlmProviderRoute(orgSlug, provider.id)}
className="block overflow-hidden rounded-[28px] border border-gray-200 bg-white p-6 transition hover:-translate-y-0.5 hover:border-gray-300 hover:shadow-[0_18px_40px_-24px_rgba(15,23,42,0.25)]"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-700">
<Cpu className="h-3.5 w-3.5" />
{getProviderSourceLabel(provider.source)}
</div>
<h2 className="mt-4 text-[22px] font-semibold tracking-[-0.05em] text-gray-950">{provider.name}</h2>
<p className="mt-2 text-[14px] text-gray-500">{provider.providerId}</p>
</div>
<div className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-[12px] font-medium text-gray-600">
{provider.models.length} {provider.models.length === 1 ? "model" : "models"}
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-[12px] font-medium ${provider.hasApiKey ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"}`}>
<KeyRound className="h-3.5 w-3.5" />
{provider.hasApiKey ? "Credential saved" : "Credential missing"}
</span>
{envNames.slice(0, 2).map((envName) => (
<span key={envName} className="rounded-full bg-gray-100 px-3 py-1 text-[12px] font-medium text-gray-600">
{envName}
</span>
))}
{envNames.length > 2 ? (
<span className="rounded-full bg-gray-100 px-3 py-1 text-[12px] font-medium text-gray-600">
+{envNames.length - 2} more keys
</span>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-[24px] bg-gray-50 p-4 text-[13px] text-gray-600 sm:grid-cols-2">
<div>
<p className="font-medium text-gray-900">Access</p>
<p className="mt-1">{memberAccessCount} people · {teamAccessCount} teams</p>
</div>
<div>
<p className="font-medium text-gray-900">Updated</p>
<p className="mt-1">{formatProviderTimestamp(provider.updatedAt)}</p>
</div>
</div>
</Link>
);
})}
</div>
)}
</DashboardPageTemplate>
);
}

View File

@@ -100,7 +100,7 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) {
return "Shared Workspaces";
}
if (pathname.startsWith(getCustomLlmProvidersRoute(orgSlug))) {
return "Custom LLMs";
return "LLM Providers";
}
if (pathname.startsWith(getSkillHubsRoute(orgSlug))) {
return "Skill Hubs";
@@ -149,12 +149,12 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) {
icon: Bot,
badge: "Alpha",
},
{
href: activeOrg ? getCustomLlmProvidersRoute(activeOrg.slug) : "#",
label: "Custom LLMs",
icon: Cpu,
badge: "Soon",
},
{
href: activeOrg ? getCustomLlmProvidersRoute(activeOrg.slug) : "#",
label: "LLM Providers",
icon: Cpu,
badge: "New",
},
{
href: activeOrg ? getSkillHubsRoute(activeOrg.slug) : "#",
label: "Skill Hubs",

View File

@@ -0,0 +1,10 @@
import { LlmProviderEditorScreen } from "../../../_components/llm-provider-editor-screen";
export default async function EditLlmProviderPage({
params,
}: {
params: Promise<{ llmProviderId: string }>;
}) {
const { llmProviderId } = await params;
return <LlmProviderEditorScreen llmProviderId={llmProviderId} />;
}

View File

@@ -0,0 +1,10 @@
import { LlmProviderDetailScreen } from "../../_components/llm-provider-detail-screen";
export default async function LlmProviderPage({
params,
}: {
params: Promise<{ llmProviderId: string }>;
}) {
const { llmProviderId } = await params;
return <LlmProviderDetailScreen llmProviderId={llmProviderId} />;
}

View File

@@ -0,0 +1,5 @@
import { LlmProviderEditorScreen } from "../../_components/llm-provider-editor-screen";
export default function NewLlmProviderPage() {
return <LlmProviderEditorScreen />;
}

View File

@@ -1,5 +1,5 @@
import { CustomLlmProvidersScreen } from "../_components/custom-llm-providers-screen";
import { LlmProvidersScreen } from "../_components/llm-providers-screen";
export default function CustomLlmProvidersPage() {
return <CustomLlmProvidersScreen />;
return <LlmProvidersScreen />;
}

View File

@@ -1,6 +1,10 @@
# MySQL mode: if DATABASE_URL is set, den-db uses mysql/mysql2.
DATABASE_URL=
# Required dedicated DB encryption key for encrypted columns. Minimum 32 chars.
# Generate one with: openssl rand -base64 128
DEN_DB_ENCRYPTION_KEY=
# PlanetScale mode: used when DATABASE_URL is not set.
DATABASE_HOST=
DATABASE_USERNAME=

View File

@@ -0,0 +1,45 @@
CREATE TABLE `llm_provider_access` (
`id` varchar(64) NOT NULL,
`llm_provider_id` varchar(64) NOT NULL,
`org_membership_id` varchar(64),
`team_id` varchar(64),
`created_at` timestamp(3) NOT NULL DEFAULT (now()),
CONSTRAINT `llm_provider_access_id` PRIMARY KEY(`id`),
CONSTRAINT `llm_provider_access_provider_org_membership` UNIQUE(`llm_provider_id`,`org_membership_id`),
CONSTRAINT `llm_provider_access_provider_team` UNIQUE(`llm_provider_id`,`team_id`)
);
--> statement-breakpoint
CREATE TABLE `llm_provider_model` (
`id` varchar(64) NOT NULL,
`llm_provider_id` varchar(64) NOT NULL,
`model_id` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`model_config` json NOT NULL,
`created_at` timestamp(3) NOT NULL DEFAULT (now()),
CONSTRAINT `llm_provider_model_id` PRIMARY KEY(`id`),
CONSTRAINT `llm_provider_model_provider_model` UNIQUE(`llm_provider_id`,`model_id`)
);
--> statement-breakpoint
CREATE TABLE `llm_provider` (
`id` varchar(64) NOT NULL,
`organization_id` varchar(64) NOT NULL,
`created_by_org_membership_id` varchar(64) NOT NULL,
`source` enum('models_dev','custom') NOT NULL,
`provider_id` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`provider_config` json NOT NULL,
`api_key` text,
`created_at` timestamp(3) NOT NULL DEFAULT (now()),
`updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT `llm_provider_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `llm_provider_access_llm_provider_id` ON `llm_provider_access` (`llm_provider_id`);--> statement-breakpoint
CREATE INDEX `llm_provider_access_org_membership_id` ON `llm_provider_access` (`org_membership_id`);--> statement-breakpoint
CREATE INDEX `llm_provider_access_team_id` ON `llm_provider_access` (`team_id`);--> statement-breakpoint
CREATE INDEX `llm_provider_model_llm_provider_id` ON `llm_provider_model` (`llm_provider_id`);--> statement-breakpoint
CREATE INDEX `llm_provider_model_model_id` ON `llm_provider_model` (`model_id`);--> statement-breakpoint
CREATE INDEX `llm_provider_organization_id` ON `llm_provider` (`organization_id`);--> statement-breakpoint
CREATE INDEX `llm_provider_created_by_org_membership_id` ON `llm_provider` (`created_by_org_membership_id`);--> statement-breakpoint
CREATE INDEX `llm_provider_source` ON `llm_provider` (`source`);--> statement-breakpoint
CREATE INDEX `llm_provider_provider_id` ON `llm_provider` (`provider_id`);

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,13 @@
"when": 1775170250887,
"tag": "0007_organization_metadata_limits",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1775261156284,
"tag": "0008_cynical_boomerang",
"breakpoints": true
}
]
}
}

View File

@@ -1,36 +1,130 @@
import { customType, timestamp, varchar } from "drizzle-orm/mysql-core";
import * as crypto from "node:crypto"
import { customType, timestamp, varchar } from "drizzle-orm/mysql-core"
import {
type DenTypeId,
type DenTypeIdName,
normalizeDenTypeId,
} from "@openwork-ee/utils/typeid";
import { sql } from "drizzle-orm";
type DenTypeId,
type DenTypeIdName,
normalizeDenTypeId,
} from "@openwork-ee/utils/typeid"
import { sql } from "drizzle-orm"
const INTERNAL_ID_LENGTH = 64;
const AUTH_EXTERNAL_ID_LENGTH = 36;
const INTERNAL_ID_LENGTH = 64
const AUTH_EXTERNAL_ID_LENGTH = 36
const ENCRYPTION_VERSION_PREFIX = "enc:v1:"
const ENCRYPTION_ALGORITHM = "aes-256-gcm"
const MIN_ENCRYPTION_KEY_LENGTH = 32
export const authExternalIdColumn = (columnName: string) =>
varchar(columnName, { length: AUTH_EXTERNAL_ID_LENGTH });
varchar(columnName, { length: AUTH_EXTERNAL_ID_LENGTH })
function getDatabaseEncryptionSecret() {
const explicit = process.env.DEN_DB_ENCRYPTION_KEY?.trim()
if (!explicit) {
throw new Error(
"DEN_DB_ENCRYPTION_KEY is required to use encrypted database columns",
)
}
if (explicit.length < MIN_ENCRYPTION_KEY_LENGTH) {
throw new Error(
`DEN_DB_ENCRYPTION_KEY must be at least ${MIN_ENCRYPTION_KEY_LENGTH} characters long`,
)
}
return explicit
}
function getDatabaseEncryptionKey() {
return new Uint8Array(
crypto.createHash("sha256").update(getDatabaseEncryptionSecret()).digest(),
)
}
function encryptDatabaseValue(value: string) {
const ivBuffer = crypto.randomBytes(12)
const iv = new Uint8Array(ivBuffer)
const cipher = crypto.createCipheriv(
ENCRYPTION_ALGORITHM,
getDatabaseEncryptionKey(),
iv,
)
let encrypted = cipher.update(value, "utf8", "base64")
encrypted += cipher.final("base64")
const authTag = cipher.getAuthTag().toString("base64")
const ivBase64 = ivBuffer.toString("base64")
return `${ENCRYPTION_VERSION_PREFIX}${ivBase64}.${authTag}.${encrypted}`
}
function decryptDatabaseValue(value: string) {
if (!value.startsWith(ENCRYPTION_VERSION_PREFIX)) {
throw new Error("Encrypted value is missing a supported prefix")
}
const [ivBase64, authTagBase64, encrypted] = value
.slice(ENCRYPTION_VERSION_PREFIX.length)
.split(".")
if (!ivBase64 || !authTagBase64 || !encrypted) {
throw new Error("Encrypted value is malformed")
}
const iv = new Uint8Array(Buffer.from(ivBase64, "base64"))
const decipher = crypto.createDecipheriv(
ENCRYPTION_ALGORITHM,
getDatabaseEncryptionKey(),
iv,
)
decipher.setAuthTag(new Uint8Array(Buffer.from(authTagBase64, "base64")))
let decrypted = decipher.update(encrypted, "base64", "utf8")
decrypted += decipher.final("utf8")
return decrypted
}
type EncryptedColumnOptions<TData> = {
serialize: (value: TData) => string
deserialize: (value: string) => TData
}
export function encryptedColumn<TData>(
columnName: string,
options: EncryptedColumnOptions<TData>,
) {
return customType<{ data: TData; driverData: string }>({
dataType() {
return "text"
},
toDriver(value) {
return encryptDatabaseValue(options.serialize(value))
},
fromDriver(value) {
return options.deserialize(decryptDatabaseValue(value))
},
})(columnName)
}
export const encryptedTextColumn = (columnName: string) =>
encryptedColumn<string>(columnName, {
serialize: (value) => value,
deserialize: (value) => value,
})
export const denTypeIdColumn = <TName extends DenTypeIdName>(
name: TName,
columnName: string,
name: TName,
columnName: string,
) =>
customType<{ data: DenTypeId<TName>; driverData: string }>({
dataType() {
return `varchar(${INTERNAL_ID_LENGTH})`;
},
toDriver(value) {
return normalizeDenTypeId(name, value);
},
fromDriver(value) {
return normalizeDenTypeId(name, value);
},
})(columnName);
customType<{ data: DenTypeId<TName>; driverData: string }>({
dataType() {
return `varchar(${INTERNAL_ID_LENGTH})`
},
toDriver(value) {
return normalizeDenTypeId(name, value)
},
fromDriver(value) {
return normalizeDenTypeId(name, value)
},
})(columnName)
export const timestamps = {
created_at: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
updated_at: timestamp("updated_at", { fsp: 3 })
.notNull()
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
};
created_at: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
updated_at: timestamp("updated_at", { fsp: 3 })
.notNull()
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
}

View File

@@ -1,5 +1,6 @@
export * from "./auth"
export * from "./org"
export * from "./sharables/llm-providers"
export * from "./sharables/skills"
export * from "./teams"
export * from "./workers"

View File

@@ -0,0 +1,138 @@
import { relations, sql } from "drizzle-orm"
import {
index,
json,
mysqlEnum,
mysqlTable,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/mysql-core"
import { denTypeIdColumn, encryptedTextColumn } from "../../columns"
import { MemberTable, OrganizationTable } from "../org"
import { TeamTable } from "../teams"
export const LlmProviderTable = mysqlTable(
"llm_provider",
{
id: denTypeIdColumn("llmProvider", "id").notNull().primaryKey(),
organizationId: denTypeIdColumn(
"organization",
"organization_id",
).notNull(),
createdByOrgMembershipId: denTypeIdColumn(
"member",
"created_by_org_membership_id",
).notNull(),
source: mysqlEnum("source", ["models_dev", "custom"]).notNull(),
providerId: varchar("provider_id", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
providerConfig: json("provider_config")
.$type<Record<string, unknown>>()
.notNull(),
apiKey: encryptedTextColumn("api_key"),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { fsp: 3 })
.notNull()
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
},
(table) => [
index("llm_provider_organization_id").on(table.organizationId),
index("llm_provider_created_by_org_membership_id").on(
table.createdByOrgMembershipId,
),
index("llm_provider_source").on(table.source),
index("llm_provider_provider_id").on(table.providerId),
],
)
export const LlmProviderModelTable = mysqlTable(
"llm_provider_model",
{
id: denTypeIdColumn("llmProviderModel", "id").notNull().primaryKey(),
llmProviderId: denTypeIdColumn("llmProvider", "llm_provider_id").notNull(),
modelId: varchar("model_id", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
modelConfig: json("model_config")
.$type<Record<string, unknown>>()
.notNull(),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
},
(table) => [
index("llm_provider_model_llm_provider_id").on(table.llmProviderId),
index("llm_provider_model_model_id").on(table.modelId),
uniqueIndex("llm_provider_model_provider_model").on(
table.llmProviderId,
table.modelId,
),
],
)
export const LlmProviderAccessTable = mysqlTable(
"llm_provider_access",
{
id: denTypeIdColumn("llmProviderAccess", "id").notNull().primaryKey(),
llmProviderId: denTypeIdColumn("llmProvider", "llm_provider_id").notNull(),
orgMembershipId: denTypeIdColumn("member", "org_membership_id"),
teamId: denTypeIdColumn("team", "team_id"),
createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(),
},
(table) => [
index("llm_provider_access_llm_provider_id").on(table.llmProviderId),
index("llm_provider_access_org_membership_id").on(table.orgMembershipId),
index("llm_provider_access_team_id").on(table.teamId),
uniqueIndex("llm_provider_access_provider_org_membership").on(
table.llmProviderId,
table.orgMembershipId,
),
uniqueIndex("llm_provider_access_provider_team").on(
table.llmProviderId,
table.teamId,
),
],
)
export const llmProviderRelations = relations(LlmProviderTable, ({ many, one }) => ({
organization: one(OrganizationTable, {
fields: [LlmProviderTable.organizationId],
references: [OrganizationTable.id],
}),
createdByOrgMembership: one(MemberTable, {
fields: [LlmProviderTable.createdByOrgMembershipId],
references: [MemberTable.id],
}),
models: many(LlmProviderModelTable),
accessLinks: many(LlmProviderAccessTable),
}))
export const llmProviderModelRelations = relations(
LlmProviderModelTable,
({ one }) => ({
llmProvider: one(LlmProviderTable, {
fields: [LlmProviderModelTable.llmProviderId],
references: [LlmProviderTable.id],
}),
}),
)
export const llmProviderAccessRelations = relations(
LlmProviderAccessTable,
({ one }) => ({
llmProvider: one(LlmProviderTable, {
fields: [LlmProviderAccessTable.llmProviderId],
references: [LlmProviderTable.id],
}),
orgMembership: one(MemberTable, {
fields: [LlmProviderAccessTable.orgMembershipId],
references: [MemberTable.id],
}),
team: one(TeamTable, {
fields: [LlmProviderAccessTable.teamId],
references: [TeamTable.id],
}),
}),
)
export const llmProvider = LlmProviderTable
export const llmProviderModel = LlmProviderModelTable
export const llmProviderAccess = LlmProviderAccessTable

View File

@@ -18,6 +18,9 @@ export const denTypeIdPrefixes = {
skillHub: "shb",
skillHubSkill: "shs",
skillHubMember: "shm",
llmProvider: "lpr",
llmProviderModel: "lpm",
llmProviderAccess: "lpa",
organizationRole: "orl",
tempTemplateSharing: "tts",
adminAllowlist: "aal",

View File

@@ -10,6 +10,12 @@ set -euo pipefail
# - Cloud web app URL
# - Den control plane demo/API URL
# - Runtime env file path with ports + project name
#
# Notes:
# - This script auto-generates a Better Auth secret per run.
# - It also uses a premade dev-only DB encryption key unless you override
# DEN_DB_ENCRYPTION_KEY yourself.
# - Generate a replacement with: openssl rand -base64 128
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COMPOSE_FILE="$ROOT_DIR/packaging/docker/docker-compose.den-dev.yml"
@@ -136,6 +142,7 @@ LAN_IPV4="$(detect_lan_ipv4 || true)"
TAILSCALE_DNS_NAME="$(detect_tailscale_dns_name || true)"
DEN_BETTER_AUTH_SECRET="${DEN_BETTER_AUTH_SECRET:-$(random_hex 32)}"
DEN_DB_ENCRYPTION_KEY="${DEN_DB_ENCRYPTION_KEY:-dev-den-db-encryption-key-please-change-1234567890}"
DEN_BETTER_AUTH_URL="${DEN_BETTER_AUTH_URL:-http://$PUBLIC_HOST:$DEN_WEB_PORT}"
DEN_PROVISIONER_MODE="${DEN_PROVISIONER_MODE:-stub}"
DEN_WORKER_URL_TEMPLATE="${DEN_WORKER_URL_TEMPLATE:-https://workers.local/{workerId}}"
@@ -202,6 +209,7 @@ DEN_WEB_PUBLIC_URL=http://$PUBLIC_HOST:$DEN_WEB_PORT
DEN_WORKER_PROXY_PUBLIC_URL=http://$PUBLIC_HOST:$DEN_WORKER_PROXY_PORT
DEN_MYSQL_URL=mysql://root:password@127.0.0.1:$DEN_MYSQL_PORT/openwork_den
DEN_BETTER_AUTH_URL=$DEN_BETTER_AUTH_URL
DEN_DB_ENCRYPTION_KEY=$DEN_DB_ENCRYPTION_KEY
COMPOSE_FILE=$COMPOSE_FILE
DEN_WATCH_OTP_CODES=$DEN_WATCH_OTP_CODES
EOF
@@ -212,6 +220,7 @@ echo "- DEN_WEB_PORT=$DEN_WEB_PORT" >&2
echo "- DEN_WORKER_PROXY_PORT=$DEN_WORKER_PROXY_PORT" >&2
echo "- DEN_MYSQL_PORT=$DEN_MYSQL_PORT" >&2
echo "- DEN_BETTER_AUTH_URL=$DEN_BETTER_AUTH_URL" >&2
echo "- DEN_DB_ENCRYPTION_KEY=[set] (dev default unless overridden)" >&2
echo "- DEN_PROVISIONER_MODE=$DEN_PROVISIONER_MODE" >&2
echo "- OTP verification codes will stream back to this terminal" >&2
if [ "$DEN_PROVISIONER_MODE" = "daytona" ]; then
@@ -226,6 +235,7 @@ if ! DEN_API_PORT="$DEN_API_PORT" \
DEN_WORKER_PROXY_PORT="$DEN_WORKER_PROXY_PORT" \
DEN_MYSQL_PORT="$DEN_MYSQL_PORT" \
DEN_BETTER_AUTH_SECRET="$DEN_BETTER_AUTH_SECRET" \
DEN_DB_ENCRYPTION_KEY="$DEN_DB_ENCRYPTION_KEY" \
DEN_BETTER_AUTH_URL="$DEN_BETTER_AUTH_URL" \
DEN_CORS_ORIGINS="$DEN_CORS_ORIGINS" \
DEN_BETTER_AUTH_TRUSTED_ORIGINS="$DEN_BETTER_AUTH_TRUSTED_ORIGINS" \

View File

@@ -11,6 +11,9 @@
# DEN_WORKER_PROXY_PORT — host port to map to the worker proxy :8789
# DEN_MYSQL_PORT — host port to map to MySQL :3306
# DEN_BETTER_AUTH_SECRET — Better Auth secret (auto-generated by den-dev-up.sh)
# DEN_DB_ENCRYPTION_KEY — dev-only DB encryption key for encrypted columns
# — defaults to a premade local key for Docker smoke tests
# — generate a replacement with: openssl rand -base64 128
# DEN_PUBLIC_HOST — browser-facing host/IP for LAN access (set by den-dev-up.sh)
# DEN_BETTER_AUTH_URL — browser-facing auth origin (default: http://<DEN_PUBLIC_HOST>:<DEN_WEB_PORT>)
# DEN_BETTER_AUTH_TRUSTED_ORIGINS — Better Auth trusted origins (defaults to DEN_CORS_ORIGINS)
@@ -70,6 +73,7 @@ services:
OPENWORK_DEV_MODE: "1"
DATABASE_URL: mysql://root:password@mysql:3306/openwork_den
BETTER_AUTH_SECRET: ${DEN_BETTER_AUTH_SECRET:-dev-den-local-auth-secret-please-override-1234567890}
DEN_DB_ENCRYPTION_KEY: ${DEN_DB_ENCRYPTION_KEY:-dev-den-db-encryption-key-please-change-1234567890}
BETTER_AUTH_URL: ${DEN_BETTER_AUTH_URL:-http://localhost:3005}
DEN_BETTER_AUTH_TRUSTED_ORIGINS: ${DEN_BETTER_AUTH_TRUSTED_ORIGINS:-}
PORT: "8788"

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View File

@@ -14,6 +14,9 @@ const apiPort = process.env.DEN_API_PORT?.trim() || process.env.DEN_CONTROLLER_P
const workerProxyPort = process.env.DEN_WORKER_PROXY_PORT?.trim() || "8789"
const webPort = process.env.DEN_WEB_PORT?.trim() || "3005"
const databaseUrl = process.env.DATABASE_URL?.trim() || "mysql://root:password@127.0.0.1:3306/openwork_den"
const dbEncryptionKey =
process.env.DEN_DB_ENCRYPTION_KEY?.trim() ||
"local-dev-db-encryption-key-please-change-1234567890"
function detectWebOrigins() {
const origins = new Set([
@@ -196,6 +199,7 @@ async function main() {
...process.env,
OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE?.trim() || "1",
DATABASE_URL: databaseUrl,
DEN_DB_ENCRYPTION_KEY: dbEncryptionKey,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET?.trim() || "local-dev-secret-not-for-production-use!!",
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL?.trim() || `http://localhost:${webPort}`,
DEN_BETTER_AUTH_TRUSTED_ORIGINS: process.env.DEN_BETTER_AUTH_TRUSTED_ORIGINS?.trim() || webOrigins,

View File

@@ -3,6 +3,7 @@
"globalEnv": [
"OPENWORK_DEV_MODE",
"DATABASE_URL",
"DEN_DB_ENCRYPTION_KEY",
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"DEN_BETTER_AUTH_TRUSTED_ORIGINS",