mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
143
ee/apps/den-api/src/llm/models-dev.ts
Normal file
143
ee/apps/den-api/src/llm/models-dev.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
922
ee/apps/den-api/src/routes/org/llm-providers.ts
Normal file
922
ee/apps/den-api/src/routes/org/llm-providers.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LlmProviderEditorScreen } from "../../_components/llm-provider-editor-screen";
|
||||
|
||||
export default function NewLlmProviderPage() {
|
||||
return <LlmProviderEditorScreen />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
45
ee/packages/den-db/drizzle/0008_cynical_boomerang.sql
Normal file
45
ee/packages/den-db/drizzle/0008_cynical_boomerang.sql
Normal 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`);
|
||||
2358
ee/packages/den-db/drizzle/meta/0008_snapshot.json
Normal file
2358
ee/packages/den-db/drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)`),
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
138
ee/packages/den-db/src/schema/sharables/llm-providers.ts
Normal file
138
ee/packages/den-db/src/schema/sharables/llm-providers.ts
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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"
|
||||
|
||||
BIN
pr/llm-hub/den-llm-provider-encrypted-flow.png
Normal file
BIN
pr/llm-hub/den-llm-provider-encrypted-flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
BIN
pr/llm-hub/llm-provider-detail-openai.png
Normal file
BIN
pr/llm-hub/llm-provider-detail-openai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
BIN
pr/llm-hub/llm-provider-list.png
Normal file
BIN
pr/llm-hub/llm-provider-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user